deepspace 0.6.2 → 0.8.0
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 +44 -0
- package/README.md +22 -8
- package/dist/cli.js +10348 -5025
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +229 -116
- package/dist/index.js +767 -788
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +541 -159
- package/dist/worker.js +611 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +159 -825
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +1 -2
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- package/scripts/add-feature.cjs +0 -807
package/dist/server.js
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
+
// src/server/utils/cors.ts
|
|
2
|
+
function matchOrigin(origin, patterns) {
|
|
3
|
+
if (!origin) return null;
|
|
4
|
+
const matches = patterns.some((pattern) => {
|
|
5
|
+
const rx = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^./]+");
|
|
6
|
+
return new RegExp(`^${rx}$`).test(origin);
|
|
7
|
+
});
|
|
8
|
+
return matches ? origin : null;
|
|
9
|
+
}
|
|
10
|
+
function platformCorsPatterns(env) {
|
|
11
|
+
const app = env.APP_DOMAIN;
|
|
12
|
+
const platform = env.PLATFORM_DOMAIN;
|
|
13
|
+
return [
|
|
14
|
+
.../* @__PURE__ */ new Set([`https://${platform}`, `https://*.${platform}`, `https://*.${app}`]),
|
|
15
|
+
"http://localhost:*"
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/server/utils/plan-quotas.ts
|
|
20
|
+
var DEPLOYED_APPS_DEFAULTS = {
|
|
21
|
+
free: 1,
|
|
22
|
+
starter: 4,
|
|
23
|
+
premium: 10,
|
|
24
|
+
admin: 100
|
|
25
|
+
};
|
|
26
|
+
function parseQuotaLimit(raw, fallback) {
|
|
27
|
+
if (!raw) return fallback;
|
|
28
|
+
const n = Number(raw);
|
|
29
|
+
return Number.isInteger(n) && n >= 0 ? n : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/server/utils/registry-client.ts
|
|
33
|
+
var REGISTRY_INSTANCE = "v1:global";
|
|
34
|
+
var APP_ID_RE = /^(app_[0-9A-HJKMNP-TV-Z]{26}|[a-z0-9][a-z0-9_-]{0,63})$/;
|
|
35
|
+
var STRICT_APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
36
|
+
var RegistryClientError = class extends Error {
|
|
37
|
+
constructor(message, code, status) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.code = code;
|
|
40
|
+
this.status = status;
|
|
41
|
+
this.name = "RegistryClientError";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
function registryStub(ns) {
|
|
45
|
+
return ns.get(ns.idFromName(REGISTRY_INSTANCE));
|
|
46
|
+
}
|
|
47
|
+
async function callRegistry(stub, body) {
|
|
48
|
+
const res = await stub.fetch("https://registry/", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify(body)
|
|
52
|
+
});
|
|
53
|
+
const json = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new RegistryClientError(
|
|
56
|
+
json.error ?? `Registry error (${res.status})`,
|
|
57
|
+
json.code ?? "error",
|
|
58
|
+
res.status
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return json;
|
|
62
|
+
}
|
|
63
|
+
function registryErrorJson(c, err) {
|
|
64
|
+
if (err instanceof RegistryClientError) {
|
|
65
|
+
return c.json({ error: err.message, code: err.code }, err.status);
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
|
|
1
70
|
// src/server/utils/tools.ts
|
|
2
71
|
var DEFAULT_QUERY_LIMIT = 50;
|
|
3
72
|
function applyAiToolDefaults(toolName, params) {
|
|
@@ -77,72 +146,6 @@ var BUILT_IN_TOOLS = [
|
|
|
77
146
|
description: "List all users in this room",
|
|
78
147
|
params: {}
|
|
79
148
|
},
|
|
80
|
-
// Storage tools (legacy key-value)
|
|
81
|
-
{
|
|
82
|
-
name: "storage.list",
|
|
83
|
-
description: "List all keys in a storage scope",
|
|
84
|
-
params: {
|
|
85
|
-
scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
|
|
86
|
-
prefix: { type: "string", description: "Optional key prefix filter", required: false },
|
|
87
|
-
userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
name: "storage.read",
|
|
92
|
-
description: "Read one or more keys from storage",
|
|
93
|
-
params: {
|
|
94
|
-
scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
|
|
95
|
-
keys: { type: "array", description: "Array of keys to read (or single key string)", required: true },
|
|
96
|
-
userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
name: "storage.write",
|
|
101
|
-
description: "Write one or more key-value pairs to storage (batch supported)",
|
|
102
|
-
params: {
|
|
103
|
-
scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
|
|
104
|
-
data: { type: "object", description: "Object with key-value pairs to write", required: true },
|
|
105
|
-
userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: "storage.delete",
|
|
110
|
-
description: "Delete one or more keys from storage",
|
|
111
|
-
params: {
|
|
112
|
-
scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
|
|
113
|
-
keys: { type: "array", description: "Array of keys to delete", required: true },
|
|
114
|
-
userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
// Backup tools
|
|
118
|
-
{
|
|
119
|
-
name: "backup.create",
|
|
120
|
-
description: "Create a backup of the current Yjs document state to R2",
|
|
121
|
-
params: {
|
|
122
|
-
description: { type: "string", description: "Optional description for the backup", required: false }
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
name: "backup.list",
|
|
127
|
-
description: "List all available backups",
|
|
128
|
-
params: {
|
|
129
|
-
limit: { type: "number", description: "Maximum number of backups to return (default: 50)", required: false }
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
name: "backup.restore",
|
|
134
|
-
description: "Restore the Yjs document from a backup",
|
|
135
|
-
params: {
|
|
136
|
-
backupId: { type: "string", description: "The backup ID to restore from", required: true }
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
name: "backup.delete",
|
|
141
|
-
description: "Delete a specific backup",
|
|
142
|
-
params: {
|
|
143
|
-
backupId: { type: "string", description: "The backup ID to delete", required: true }
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
149
|
// Yjs tools (collaborative document access)
|
|
147
150
|
{
|
|
148
151
|
name: "yjs.list",
|
|
@@ -173,8 +176,11 @@ var BUILT_IN_TOOLS = [
|
|
|
173
176
|
// src/server/utils/scoped-r2-files.ts
|
|
174
177
|
var CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
|
175
178
|
function contentDisposition(originalName) {
|
|
176
|
-
const safe = originalName.replace(/[
|
|
177
|
-
const encoded = encodeURIComponent(originalName)
|
|
179
|
+
const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
|
|
180
|
+
const encoded = encodeURIComponent(originalName).replace(
|
|
181
|
+
/[!'()*]/g,
|
|
182
|
+
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
183
|
+
);
|
|
178
184
|
return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
|
179
185
|
}
|
|
180
186
|
function sanitizeSubpath(raw) {
|
|
@@ -183,6 +189,9 @@ function sanitizeSubpath(raw) {
|
|
|
183
189
|
if (segments.some((s) => s === ".." || s === ".")) return null;
|
|
184
190
|
return raw;
|
|
185
191
|
}
|
|
192
|
+
function encodeKeyPath(key) {
|
|
193
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
194
|
+
}
|
|
186
195
|
function isKeyWithinPrefix(key, prefix) {
|
|
187
196
|
return key.startsWith(prefix);
|
|
188
197
|
}
|
|
@@ -203,9 +212,15 @@ function normalizeMimeType(raw) {
|
|
|
203
212
|
function createScopedR2Handler(config) {
|
|
204
213
|
const requireAuthForMutations = config.requireAuthForMutations ?? true;
|
|
205
214
|
return async (request, url, bucket, auth) => {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
let subpathRaw;
|
|
216
|
+
try {
|
|
217
|
+
subpathRaw = decodeURIComponent(url.pathname.replace("/api/files", "").replace(/^\//, ""));
|
|
218
|
+
} catch {
|
|
219
|
+
return Response.json(
|
|
220
|
+
{ error: "Invalid path encoding" },
|
|
221
|
+
{ status: 400, headers: CORS_HEADERS }
|
|
222
|
+
);
|
|
223
|
+
}
|
|
209
224
|
const subpath = sanitizeSubpath(subpathRaw);
|
|
210
225
|
if (subpath === null) {
|
|
211
226
|
return Response.json(
|
|
@@ -213,9 +228,10 @@ function createScopedR2Handler(config) {
|
|
|
213
228
|
{ status: 400, headers: CORS_HEADERS }
|
|
214
229
|
);
|
|
215
230
|
}
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
231
|
+
const scope = url.searchParams.get("scope") || "self";
|
|
232
|
+
if (scope !== "self" && scope !== "app") {
|
|
233
|
+
return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS });
|
|
234
|
+
}
|
|
219
235
|
const result = config.resolvePrefix(scope, { userId: auth.userId, url });
|
|
220
236
|
if (!result.prefix) {
|
|
221
237
|
const errorMsg = result.error ?? "Invalid scope";
|
|
@@ -230,10 +246,10 @@ function createScopedR2Handler(config) {
|
|
|
230
246
|
{ status: 401, headers: CORS_HEADERS }
|
|
231
247
|
);
|
|
232
248
|
}
|
|
233
|
-
return handleUpload(request, url, bucket, prefix, auth.userId);
|
|
249
|
+
return handleUpload(request, url, bucket, prefix, auth.userId, scope);
|
|
234
250
|
}
|
|
235
251
|
if (!subpath && request.method === "GET") {
|
|
236
|
-
return handleList(bucket, prefix, url);
|
|
252
|
+
return handleList(bucket, prefix, url, scope);
|
|
237
253
|
}
|
|
238
254
|
if (subpath && request.method === "GET") {
|
|
239
255
|
if (!isKeyWithinPrefix(subpath, prefix)) {
|
|
@@ -242,7 +258,7 @@ function createScopedR2Handler(config) {
|
|
|
242
258
|
{ status: 403, headers: CORS_HEADERS }
|
|
243
259
|
);
|
|
244
260
|
}
|
|
245
|
-
return handleDownload(bucket, subpath);
|
|
261
|
+
return handleDownload(bucket, subpath, scope);
|
|
246
262
|
}
|
|
247
263
|
if (subpath && request.method === "DELETE") {
|
|
248
264
|
if (requireAuthForMutations && !auth.userId) {
|
|
@@ -263,7 +279,7 @@ function createScopedR2Handler(config) {
|
|
|
263
279
|
return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS });
|
|
264
280
|
};
|
|
265
281
|
}
|
|
266
|
-
async function handleUpload(request, url, bucket, prefix, userId) {
|
|
282
|
+
async function handleUpload(request, url, bucket, prefix, userId, scope) {
|
|
267
283
|
try {
|
|
268
284
|
const contentType = request.headers.get("content-type") || "";
|
|
269
285
|
let fileData;
|
|
@@ -327,35 +343,37 @@ async function handleUpload(request, url, bucket, prefix, userId) {
|
|
|
327
343
|
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
328
344
|
}
|
|
329
345
|
});
|
|
330
|
-
const fileUrl =
|
|
346
|
+
const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
|
|
347
|
+
fileUrl.searchParams.set("scope", scope);
|
|
331
348
|
return Response.json(
|
|
332
|
-
{ success: true, key, url: fileUrl, name: fileName },
|
|
349
|
+
{ success: true, key, url: fileUrl.toString(), name: fileName },
|
|
333
350
|
{ headers: CORS_HEADERS }
|
|
334
351
|
);
|
|
335
352
|
} catch (err) {
|
|
336
353
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
|
337
354
|
console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
|
|
338
|
-
return Response.json(
|
|
339
|
-
{ error: msg },
|
|
340
|
-
{ status: 500, headers: CORS_HEADERS }
|
|
341
|
-
);
|
|
355
|
+
return Response.json({ error: msg }, { status: 500, headers: CORS_HEADERS });
|
|
342
356
|
}
|
|
343
357
|
}
|
|
344
|
-
async function handleList(bucket, prefix, url) {
|
|
358
|
+
async function handleList(bucket, prefix, url, scope) {
|
|
345
359
|
const userPrefix = url.searchParams.get("prefix") || "";
|
|
346
360
|
const listPrefix = `${prefix}${userPrefix}`;
|
|
347
361
|
const limit = parseInt(url.searchParams.get("limit") || "100", 10);
|
|
348
362
|
const listed = await bucket.list({ prefix: listPrefix, limit });
|
|
349
|
-
const files = listed.objects.map((obj) =>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
363
|
+
const files = listed.objects.map((obj) => {
|
|
364
|
+
const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
|
|
365
|
+
fileUrl.searchParams.set("scope", scope);
|
|
366
|
+
return {
|
|
367
|
+
key: obj.key,
|
|
368
|
+
size: obj.size,
|
|
369
|
+
uploaded: obj.uploaded.toISOString(),
|
|
370
|
+
url: fileUrl.toString(),
|
|
371
|
+
...obj.customMetadata
|
|
372
|
+
};
|
|
373
|
+
});
|
|
356
374
|
return Response.json({ files, truncated: listed.truncated }, { headers: CORS_HEADERS });
|
|
357
375
|
}
|
|
358
|
-
async function handleDownload(bucket, key) {
|
|
376
|
+
async function handleDownload(bucket, key, scope) {
|
|
359
377
|
const object = await bucket.get(key);
|
|
360
378
|
if (!object) {
|
|
361
379
|
return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS });
|
|
@@ -363,7 +381,10 @@ async function handleDownload(bucket, key) {
|
|
|
363
381
|
const headers = new Headers();
|
|
364
382
|
headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
|
|
365
383
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
366
|
-
headers.set(
|
|
384
|
+
headers.set(
|
|
385
|
+
"Cache-Control",
|
|
386
|
+
scope === "app" ? "public, max-age=31536000, immutable" : "private, no-store"
|
|
387
|
+
);
|
|
367
388
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
368
389
|
if (object.customMetadata?.originalName) {
|
|
369
390
|
headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
|
|
@@ -378,7 +399,7 @@ function resolveApiTransport(env) {
|
|
|
378
399
|
return { kind: "url", baseUrl: env.API_WORKER_URL.replace(/\/$/, "") };
|
|
379
400
|
}
|
|
380
401
|
throw new Error(
|
|
381
|
-
"apiWorkerFetch: neither env.API_WORKER nor env.API_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev` write API_WORKER_URL into .dev.vars for local development."
|
|
402
|
+
"apiWorkerFetch: neither env.API_WORKER nor env.API_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev start` write API_WORKER_URL into .dev.vars for local development."
|
|
382
403
|
);
|
|
383
404
|
}
|
|
384
405
|
function resolvePlatformTransport(env) {
|
|
@@ -387,7 +408,7 @@ function resolvePlatformTransport(env) {
|
|
|
387
408
|
return { kind: "url", baseUrl: env.PLATFORM_WORKER_URL.replace(/\/$/, "") };
|
|
388
409
|
}
|
|
389
410
|
throw new Error(
|
|
390
|
-
"platformWorkerFetch: neither env.PLATFORM_WORKER nor env.PLATFORM_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev` write PLATFORM_WORKER_URL into .dev.vars for local development."
|
|
411
|
+
"platformWorkerFetch: neither env.PLATFORM_WORKER nor env.PLATFORM_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev start` write PLATFORM_WORKER_URL into .dev.vars for local development."
|
|
391
412
|
);
|
|
392
413
|
}
|
|
393
414
|
function buildTargetUrl(transport, path, internalHost) {
|
|
@@ -433,7 +454,7 @@ function platformWorkerFetch(env, pathOrRequest, init) {
|
|
|
433
454
|
function authWorkerFetch(env, path, init) {
|
|
434
455
|
if (!env.AUTH_WORKER_URL) {
|
|
435
456
|
throw new Error(
|
|
436
|
-
"authWorkerFetch: env.AUTH_WORKER_URL is not set. `deepspace dev` should write this into .dev.vars; for production, set it as a wrangler var or secret."
|
|
457
|
+
"authWorkerFetch: env.AUTH_WORKER_URL is not set. `deepspace dev start` should write this into .dev.vars; for production, set it as a wrangler var or secret."
|
|
437
458
|
);
|
|
438
459
|
}
|
|
439
460
|
const baseUrl = env.AUTH_WORKER_URL.replace(/\/$/, "");
|
|
@@ -454,15 +475,17 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
|
454
475
|
const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
|
|
455
476
|
const room = env.RECORD_ROOMS.get(roomIdObj);
|
|
456
477
|
async function executeTool3(tool2, params) {
|
|
457
|
-
const response = await room.fetch(
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
478
|
+
const response = await room.fetch(
|
|
479
|
+
new Request("https://internal/api/tools/execute", {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: {
|
|
482
|
+
"Content-Type": "application/json",
|
|
483
|
+
"X-User-Id": ownerUserId,
|
|
484
|
+
"X-App-Action": "true"
|
|
485
|
+
},
|
|
486
|
+
body: JSON.stringify({ tool: tool2, params })
|
|
487
|
+
})
|
|
488
|
+
);
|
|
466
489
|
const result = await response.json();
|
|
467
490
|
if (!result.success) {
|
|
468
491
|
throw new Error(`RecordRoom tool ${tool2} failed: ${result.error || "Unknown error"}`);
|
|
@@ -492,7 +515,7 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
|
492
515
|
async call(endpoint, params = {}) {
|
|
493
516
|
if (!env.APP_OWNER_JWT) {
|
|
494
517
|
throw new Error(
|
|
495
|
-
"integrations.call requires env.APP_OWNER_JWT. Deployed apps receive this automatically; in dev, `deepspace dev` writes it into .dev.vars."
|
|
518
|
+
"integrations.call requires env.APP_OWNER_JWT. Deployed apps receive this automatically; in dev, `deepspace dev start` writes it into .dev.vars."
|
|
496
519
|
);
|
|
497
520
|
}
|
|
498
521
|
const response = await apiWorkerFetch(env, `/api/integrations/${endpoint}`, {
|
|
@@ -543,7 +566,7 @@ function createDeepSpaceAI(env, provider, options = {}) {
|
|
|
543
566
|
const authToken = options.authToken ?? env.APP_OWNER_JWT;
|
|
544
567
|
if (!authToken) {
|
|
545
568
|
throw new Error(
|
|
546
|
-
"createDeepSpaceAI: no auth token available. Either pass `options.authToken` explicitly (for user-initiated calls), or ensure `env.APP_OWNER_JWT` is set (injected at deploy time or by `deepspace dev`)."
|
|
569
|
+
"createDeepSpaceAI: no auth token available. Either pass `options.authToken` explicitly (for user-initiated calls), or ensure `env.APP_OWNER_JWT` is set (injected at deploy time or by `deepspace dev start`)."
|
|
547
570
|
);
|
|
548
571
|
}
|
|
549
572
|
const proxyFetch = (input, init) => {
|
|
@@ -679,7 +702,7 @@ function appendAppIdentity(headers, env) {
|
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
// src/server/utils/screenshot.ts
|
|
682
|
-
async function captureScreenshot(env,
|
|
705
|
+
async function captureScreenshot(env, url) {
|
|
683
706
|
const headers = new Headers({ "content-type": "application/json" });
|
|
684
707
|
appendAppIdentity(headers, env);
|
|
685
708
|
const res = await platformWorkerFetch(
|
|
@@ -687,16 +710,11 @@ async function captureScreenshot(env, opts) {
|
|
|
687
710
|
new Request("https://platform-internal/internal/screenshot", {
|
|
688
711
|
method: "POST",
|
|
689
712
|
headers,
|
|
690
|
-
body: JSON.stringify(
|
|
713
|
+
body: JSON.stringify({ url })
|
|
691
714
|
})
|
|
692
715
|
);
|
|
693
716
|
if (!res.ok) {
|
|
694
|
-
|
|
695
|
-
try {
|
|
696
|
-
detail = (await res.text()).slice(0, 300);
|
|
697
|
-
} catch {
|
|
698
|
-
}
|
|
699
|
-
console.warn(`[captureScreenshot] platform returned ${res.status}:`, detail);
|
|
717
|
+
console.warn(`[captureScreenshot] platform returned ${res.status}`);
|
|
700
718
|
return null;
|
|
701
719
|
}
|
|
702
720
|
return {
|
|
@@ -1062,8 +1080,6 @@ async function executeTool(stub, userId, tool2, params) {
|
|
|
1062
1080
|
function toChatRow(env) {
|
|
1063
1081
|
return {
|
|
1064
1082
|
recordId: env.recordId,
|
|
1065
|
-
// Deprecated alias — populated for back-compat with callers reading `id`.
|
|
1066
|
-
id: env.recordId,
|
|
1067
1083
|
userId: env.data.userId,
|
|
1068
1084
|
title: env.data.title ?? "",
|
|
1069
1085
|
model: env.data.model,
|
|
@@ -1076,8 +1092,6 @@ function toChatRow(env) {
|
|
|
1076
1092
|
function toMessageRow(env) {
|
|
1077
1093
|
return {
|
|
1078
1094
|
recordId: env.recordId,
|
|
1079
|
-
// Deprecated alias — populated for back-compat with callers reading `id`.
|
|
1080
|
-
id: env.recordId,
|
|
1081
1095
|
chatId: env.data.chatId,
|
|
1082
1096
|
userId: env.data.userId,
|
|
1083
1097
|
role: env.data.role,
|
|
@@ -1330,7 +1344,7 @@ var BaseRoom = class {
|
|
|
1330
1344
|
// ==========================================================================
|
|
1331
1345
|
// WebSocket Connection
|
|
1332
1346
|
// ==========================================================================
|
|
1333
|
-
async handleWebSocketUpgrade(
|
|
1347
|
+
async handleWebSocketUpgrade(_request, url) {
|
|
1334
1348
|
const userId = url.searchParams.get("userId") || void 0;
|
|
1335
1349
|
const userName = url.searchParams.get("userName") || "Anonymous";
|
|
1336
1350
|
const userEmail = url.searchParams.get("userEmail") || "";
|
|
@@ -1382,7 +1396,7 @@ var BaseRoom = class {
|
|
|
1382
1396
|
}
|
|
1383
1397
|
}
|
|
1384
1398
|
}
|
|
1385
|
-
async webSocketError(
|
|
1399
|
+
async webSocketError(_ws, error) {
|
|
1386
1400
|
console.error(`[${this.constructor.name}] webSocketError:`, error);
|
|
1387
1401
|
}
|
|
1388
1402
|
async alarm() {
|
|
@@ -2382,6 +2396,11 @@ function parseYjsDocKey(docKey) {
|
|
|
2382
2396
|
fieldName: docKey.slice(lastColon + 1)
|
|
2383
2397
|
};
|
|
2384
2398
|
}
|
|
2399
|
+
function hasYjsSubscription(attachment, target) {
|
|
2400
|
+
return attachment.yjsSubscriptions.some(
|
|
2401
|
+
(subscription) => subscription.collection === target.collection && subscription.recordId === target.recordId && subscription.fieldName === target.fieldName
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2385
2404
|
var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
2386
2405
|
"canvas-settings"
|
|
2387
2406
|
]);
|
|
@@ -2397,7 +2416,7 @@ var SYSTEM_COLLECTION_SCHEMAS = [
|
|
|
2397
2416
|
function getYjsDocKey(collection, recordId, fieldName) {
|
|
2398
2417
|
return `${collection}:${recordId}:${fieldName}`;
|
|
2399
2418
|
}
|
|
2400
|
-
|
|
2419
|
+
function getOrCreateYjsDoc(ctx, docKey) {
|
|
2401
2420
|
let doc = ctx.yjsDocs.get(docKey);
|
|
2402
2421
|
if (doc) return doc;
|
|
2403
2422
|
doc = new Y2.Doc();
|
|
@@ -2419,6 +2438,20 @@ async function getOrCreateYjsDoc(ctx, docKey) {
|
|
|
2419
2438
|
ctx.yjsDocs.set(docKey, doc);
|
|
2420
2439
|
return doc;
|
|
2421
2440
|
}
|
|
2441
|
+
function unloadYjsDocIfUnused(ctx, docKey, excludeWs = null) {
|
|
2442
|
+
const target = parseYjsDocKey(docKey);
|
|
2443
|
+
if (!target) return false;
|
|
2444
|
+
for (const ws of ctx.state.getWebSockets()) {
|
|
2445
|
+
if (ws === excludeWs) continue;
|
|
2446
|
+
const attachment = ws.deserializeAttachment();
|
|
2447
|
+
if (attachment && hasYjsSubscription(attachment, target)) return false;
|
|
2448
|
+
}
|
|
2449
|
+
const doc = ctx.yjsDocs.get(docKey);
|
|
2450
|
+
if (!doc) return false;
|
|
2451
|
+
ctx.yjsDocs.delete(docKey);
|
|
2452
|
+
doc.destroy();
|
|
2453
|
+
return true;
|
|
2454
|
+
}
|
|
2422
2455
|
function saveYjsDoc(sql, docKey, doc) {
|
|
2423
2456
|
const state = Y2.encodeStateAsUpdate(doc);
|
|
2424
2457
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -2432,7 +2465,7 @@ function saveYjsDoc(sql, docKey, doc) {
|
|
|
2432
2465
|
now
|
|
2433
2466
|
);
|
|
2434
2467
|
}
|
|
2435
|
-
|
|
2468
|
+
function handleYjsJoin(ctx, ws, attachment, payload) {
|
|
2436
2469
|
const { collection, recordId, fieldName } = payload;
|
|
2437
2470
|
const isSystem = SYSTEM_COLLECTIONS.has(collection);
|
|
2438
2471
|
const schema = ctx.schemaRegistry.get(collection);
|
|
@@ -2471,10 +2504,8 @@ async function handleYjsJoin(ctx, ws, attachment, payload) {
|
|
|
2471
2504
|
hasWriteAccess = canUpdate(schema, attachment.role, recordWithId, attachment.userId, permCtx);
|
|
2472
2505
|
}
|
|
2473
2506
|
const docKey = getYjsDocKey(collection, recordId, fieldName);
|
|
2474
|
-
const doc =
|
|
2475
|
-
if (!attachment
|
|
2476
|
-
(s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
|
|
2477
|
-
)) {
|
|
2507
|
+
const doc = getOrCreateYjsDoc(ctx, docKey);
|
|
2508
|
+
if (!hasYjsSubscription(attachment, payload)) {
|
|
2478
2509
|
attachment.yjsSubscriptions.push({ collection, recordId, fieldName });
|
|
2479
2510
|
ws.serializeAttachment(attachment);
|
|
2480
2511
|
}
|
|
@@ -2496,14 +2527,15 @@ async function handleYjsJoin(ctx, ws, attachment, payload) {
|
|
|
2496
2527
|
}
|
|
2497
2528
|
ctx.send(ws, { type: MSG.YJS_JOIN, payload: { collection, recordId, fieldName, canWrite: hasWriteAccess } });
|
|
2498
2529
|
}
|
|
2499
|
-
function handleYjsLeave(ws, attachment, payload) {
|
|
2530
|
+
function handleYjsLeave(ctx, ws, attachment, payload) {
|
|
2500
2531
|
const { collection, recordId, fieldName } = payload;
|
|
2501
2532
|
attachment.yjsSubscriptions = attachment.yjsSubscriptions.filter(
|
|
2502
2533
|
(s) => !(s.collection === collection && s.recordId === recordId && s.fieldName === fieldName)
|
|
2503
2534
|
);
|
|
2504
2535
|
ws.serializeAttachment(attachment);
|
|
2536
|
+
unloadYjsDocIfUnused(ctx, getYjsDocKey(collection, recordId, fieldName), ws);
|
|
2505
2537
|
}
|
|
2506
|
-
|
|
2538
|
+
function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
2507
2539
|
const decoder = createDecoder(data);
|
|
2508
2540
|
const messageType = readVarUint(decoder);
|
|
2509
2541
|
if (messageType === MSG_YJS_AWARENESS) {
|
|
@@ -2512,11 +2544,7 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
|
2512
2544
|
const payload = readVarUint8Array(decoder);
|
|
2513
2545
|
const parsed2 = parseYjsDocKey(docKey2);
|
|
2514
2546
|
if (!parsed2) return;
|
|
2515
|
-
|
|
2516
|
-
const isSubscribed2 = attachment.yjsSubscriptions.some(
|
|
2517
|
-
(s) => s.collection === collection2 && s.recordId === recordId2 && s.fieldName === fieldName2
|
|
2518
|
-
);
|
|
2519
|
-
if (!isSubscribed2) return;
|
|
2547
|
+
if (!hasYjsSubscription(attachment, parsed2)) return;
|
|
2520
2548
|
if (attachment.awarenessClientId == null) {
|
|
2521
2549
|
try {
|
|
2522
2550
|
const pd = createDecoder(payload);
|
|
@@ -2536,12 +2564,9 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
|
2536
2564
|
const docKey = new TextDecoder().decode(docKeyBytes);
|
|
2537
2565
|
const parsed = parseYjsDocKey(docKey);
|
|
2538
2566
|
if (!parsed) return;
|
|
2539
|
-
const { collection, recordId
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
);
|
|
2543
|
-
if (!isSubscribed) return;
|
|
2544
|
-
const doc = await getOrCreateYjsDoc(ctx, docKey);
|
|
2567
|
+
const { collection, recordId } = parsed;
|
|
2568
|
+
if (!hasYjsSubscription(attachment, parsed)) return;
|
|
2569
|
+
const doc = getOrCreateYjsDoc(ctx, docKey);
|
|
2545
2570
|
const syncType = readVarUint(decoder);
|
|
2546
2571
|
switch (syncType) {
|
|
2547
2572
|
case MSG_SYNC_STEP1: {
|
|
@@ -2566,7 +2591,6 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
|
2566
2591
|
}
|
|
2567
2592
|
const update = readVarUint8Array(decoder);
|
|
2568
2593
|
Y2.applyUpdate(doc, update, "client");
|
|
2569
|
-
saveYjsDoc(ctx.sql, docKey, doc);
|
|
2570
2594
|
broadcastYjsUpdate(ctx, docKey, update, ws);
|
|
2571
2595
|
break;
|
|
2572
2596
|
}
|
|
@@ -2575,7 +2599,6 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
|
2575
2599
|
function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
|
|
2576
2600
|
const parsed = parseYjsDocKey(docKey);
|
|
2577
2601
|
if (!parsed) return;
|
|
2578
|
-
const { collection, recordId, fieldName } = parsed;
|
|
2579
2602
|
const encoder = createEncoder();
|
|
2580
2603
|
writeVarUint(encoder, MSG_YJS_SYNC);
|
|
2581
2604
|
writeVarUint8Array(encoder, new TextEncoder().encode(docKey));
|
|
@@ -2586,10 +2609,7 @@ function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
|
|
|
2586
2609
|
if (ws === excludeWs) continue;
|
|
2587
2610
|
const attachment = ws.deserializeAttachment();
|
|
2588
2611
|
if (!attachment) continue;
|
|
2589
|
-
|
|
2590
|
-
(s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
|
|
2591
|
-
);
|
|
2592
|
-
if (isSubscribed) {
|
|
2612
|
+
if (hasYjsSubscription(attachment, parsed)) {
|
|
2593
2613
|
ctx.sendBinary(ws, message);
|
|
2594
2614
|
}
|
|
2595
2615
|
}
|
|
@@ -2597,7 +2617,6 @@ function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
|
|
|
2597
2617
|
function broadcastAwareness(ctx, docKey, payload, excludeWs) {
|
|
2598
2618
|
const parsed = parseYjsDocKey(docKey);
|
|
2599
2619
|
if (!parsed) return;
|
|
2600
|
-
const { collection, recordId, fieldName } = parsed;
|
|
2601
2620
|
const encoder = createEncoder();
|
|
2602
2621
|
writeVarUint(encoder, MSG_YJS_AWARENESS);
|
|
2603
2622
|
writeVarUint8Array(encoder, new TextEncoder().encode(docKey));
|
|
@@ -2607,10 +2626,7 @@ function broadcastAwareness(ctx, docKey, payload, excludeWs) {
|
|
|
2607
2626
|
if (ws === excludeWs) continue;
|
|
2608
2627
|
const attachment = ws.deserializeAttachment();
|
|
2609
2628
|
if (!attachment) continue;
|
|
2610
|
-
|
|
2611
|
-
(s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
|
|
2612
|
-
);
|
|
2613
|
-
if (isSubscribed) {
|
|
2629
|
+
if (hasYjsSubscription(attachment, parsed)) {
|
|
2614
2630
|
ctx.sendBinary(ws, message);
|
|
2615
2631
|
}
|
|
2616
2632
|
}
|
|
@@ -2629,6 +2645,17 @@ function broadcastAwarenessRemoval(ctx, attachment) {
|
|
|
2629
2645
|
broadcastAwareness(ctx, docKey, removalPayload, null);
|
|
2630
2646
|
}
|
|
2631
2647
|
}
|
|
2648
|
+
function handleYjsDisconnect(ctx, ws, attachment) {
|
|
2649
|
+
broadcastAwarenessRemoval(ctx, attachment);
|
|
2650
|
+
const docKeys = new Set(
|
|
2651
|
+
attachment.yjsSubscriptions.map(
|
|
2652
|
+
(subscription) => getYjsDocKey(subscription.collection, subscription.recordId, subscription.fieldName)
|
|
2653
|
+
)
|
|
2654
|
+
);
|
|
2655
|
+
for (const docKey of docKeys) {
|
|
2656
|
+
unloadYjsDocIfUnused(ctx, docKey, ws);
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2632
2659
|
|
|
2633
2660
|
// src/server/handlers/subscriptions.ts
|
|
2634
2661
|
function handleSubscribe(ctx, ws, attachment, payload) {
|
|
@@ -2962,7 +2989,7 @@ function handleUserList(ctx, ws, _attachment) {
|
|
|
2962
2989
|
}));
|
|
2963
2990
|
ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
|
|
2964
2991
|
}
|
|
2965
|
-
function handleUserUpdate(ctx,
|
|
2992
|
+
function handleUserUpdate(ctx, _ws, attachment, payload) {
|
|
2966
2993
|
const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
|
|
2967
2994
|
if (!existing) return;
|
|
2968
2995
|
const data = { lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
@@ -3067,7 +3094,7 @@ async function handleToolExecute(ctx, request) {
|
|
|
3067
3094
|
}
|
|
3068
3095
|
}
|
|
3069
3096
|
if (tool2.startsWith("yjs.")) {
|
|
3070
|
-
const result2 =
|
|
3097
|
+
const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
|
|
3071
3098
|
const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
|
|
3072
3099
|
return Response.json(result2, { status: status2, headers: CORS_HEADERS2 });
|
|
3073
3100
|
}
|
|
@@ -3222,7 +3249,15 @@ function buildYjsCtx(ctx) {
|
|
|
3222
3249
|
sendBinary: ctx.sendBinary
|
|
3223
3250
|
};
|
|
3224
3251
|
}
|
|
3225
|
-
|
|
3252
|
+
function withYjsDoc(ctx, docKey, operation) {
|
|
3253
|
+
const doc = getOrCreateYjsDoc(ctx, docKey);
|
|
3254
|
+
try {
|
|
3255
|
+
return operation(doc);
|
|
3256
|
+
} finally {
|
|
3257
|
+
unloadYjsDocIfUnused(ctx, docKey);
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
function executeYjsTool(ctx, toolName, params, userId, userRole) {
|
|
3226
3261
|
switch (toolName) {
|
|
3227
3262
|
case "yjs.list": {
|
|
3228
3263
|
const rows = ctx.sql.exec(
|
|
@@ -3250,20 +3285,21 @@ async function executeYjsTool(ctx, toolName, params, userId, userRole) {
|
|
|
3250
3285
|
if (permError) return permError;
|
|
3251
3286
|
const docKey = getYjsDocKey(collection, recordId, fieldName);
|
|
3252
3287
|
const yjsCtx = buildYjsCtx(ctx);
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3288
|
+
return withYjsDoc(yjsCtx, docKey, (doc) => {
|
|
3289
|
+
const ytext = doc.getText(fieldName);
|
|
3290
|
+
const text = ytext.toString();
|
|
3291
|
+
const sharedTypes = {};
|
|
3292
|
+
for (const [key, type] of doc.share) {
|
|
3293
|
+
if (type instanceof Y3.Text) {
|
|
3294
|
+
sharedTypes[key || "(default)"] = `Y.Text (${type.length} chars)`;
|
|
3295
|
+
} else if (type instanceof Y3.Map) {
|
|
3296
|
+
sharedTypes[key] = `Y.Map (${type.size} entries)`;
|
|
3297
|
+
} else if (type instanceof Y3.Array) {
|
|
3298
|
+
sharedTypes[key] = `Y.Array (${type.length} items)`;
|
|
3299
|
+
}
|
|
3264
3300
|
}
|
|
3265
|
-
|
|
3266
|
-
|
|
3301
|
+
return { success: true, data: { docKey, text, sharedTypes } };
|
|
3302
|
+
});
|
|
3267
3303
|
}
|
|
3268
3304
|
case "yjs.setText": {
|
|
3269
3305
|
const { collection, recordId, fieldName, text } = params;
|
|
@@ -3277,22 +3313,26 @@ async function executeYjsTool(ctx, toolName, params, userId, userRole) {
|
|
|
3277
3313
|
if (permError) return permError;
|
|
3278
3314
|
const docKey = getYjsDocKey(collection, recordId, fieldName);
|
|
3279
3315
|
const yjsCtx = buildYjsCtx(ctx);
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3316
|
+
return withYjsDoc(yjsCtx, docKey, (doc) => {
|
|
3317
|
+
const ytext = doc.getText(fieldName);
|
|
3318
|
+
let capturedUpdate = null;
|
|
3319
|
+
const updateHandler = (update) => {
|
|
3320
|
+
capturedUpdate = update;
|
|
3321
|
+
};
|
|
3322
|
+
doc.on("update", updateHandler);
|
|
3323
|
+
try {
|
|
3324
|
+
doc.transact(() => {
|
|
3325
|
+
ytext.delete(0, ytext.length);
|
|
3326
|
+
ytext.insert(0, text);
|
|
3327
|
+
});
|
|
3328
|
+
} finally {
|
|
3329
|
+
doc.off("update", updateHandler);
|
|
3330
|
+
}
|
|
3331
|
+
if (capturedUpdate) {
|
|
3332
|
+
broadcastYjsUpdate(yjsCtx, docKey, capturedUpdate, null);
|
|
3333
|
+
}
|
|
3334
|
+
return { success: true, data: { docKey, length: text.length } };
|
|
3290
3335
|
});
|
|
3291
|
-
doc.off("update", updateHandler);
|
|
3292
|
-
if (capturedUpdate) {
|
|
3293
|
-
broadcastYjsUpdate(yjsCtx, docKey, capturedUpdate, null);
|
|
3294
|
-
}
|
|
3295
|
-
return { success: true, data: { docKey, length: text.length } };
|
|
3296
3336
|
}
|
|
3297
3337
|
default:
|
|
3298
3338
|
return { success: false, error: `Unknown Yjs tool: ${toolName}` };
|
|
@@ -3535,8 +3575,6 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3535
3575
|
initPromise = null;
|
|
3536
3576
|
/** Yjs docs loaded in memory (key: collection:recordId:fieldName) */
|
|
3537
3577
|
yjsDocs = /* @__PURE__ */ new Map();
|
|
3538
|
-
/** Next Yjs client ID counter */
|
|
3539
|
-
nextYjsClientId = 1;
|
|
3540
3578
|
/** Owner user ID — gets admin role automatically */
|
|
3541
3579
|
ownerUserId;
|
|
3542
3580
|
/** True until the first fetch() completes — detects hibernation wake-up */
|
|
@@ -3553,7 +3591,7 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3553
3591
|
* The HTTP debug API (`/api/debug/*`) runs arbitrary SQL and role changes
|
|
3554
3592
|
* with no auth of its own, so it is gated here at the DO's single ingress.
|
|
3555
3593
|
* Off unless a deployment opts in with `ALLOW_DEBUG_ROUTES=true`
|
|
3556
|
-
* (`deepspace dev`/`test` set it automatically). Deployments holding shared
|
|
3594
|
+
* (`deepspace dev start`/`test run` set it automatically). Deployments holding shared
|
|
3557
3595
|
* data override this to always return false.
|
|
3558
3596
|
*/
|
|
3559
3597
|
get debugRoutesEnabled() {
|
|
@@ -3688,13 +3726,17 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3688
3726
|
this.sql.exec(`DROP TABLE IF EXISTS records`);
|
|
3689
3727
|
return;
|
|
3690
3728
|
}
|
|
3691
|
-
|
|
3692
|
-
|
|
3729
|
+
const prepared = [];
|
|
3730
|
+
const blocked = {
|
|
3731
|
+
missingSchema: 0,
|
|
3732
|
+
invalidData: 0,
|
|
3733
|
+
targetConflict: 0
|
|
3734
|
+
};
|
|
3693
3735
|
for (const row of rows) {
|
|
3694
3736
|
const r = row;
|
|
3695
3737
|
const schema = this.schemaRegistry.get(r.collection);
|
|
3696
3738
|
if (!schema || !schema.columns) {
|
|
3697
|
-
|
|
3739
|
+
blocked.missingSchema++;
|
|
3698
3740
|
continue;
|
|
3699
3741
|
}
|
|
3700
3742
|
const tbl = collectionTableName(r.collection);
|
|
@@ -3704,14 +3746,18 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3704
3746
|
}
|
|
3705
3747
|
const existing = this.sql.exec(`SELECT 1 FROM "${tbl}" WHERE _row_id = ?`, r.record_id).toArray();
|
|
3706
3748
|
if (existing.length > 0) {
|
|
3707
|
-
|
|
3749
|
+
blocked.targetConflict++;
|
|
3708
3750
|
continue;
|
|
3709
3751
|
}
|
|
3710
3752
|
let data;
|
|
3711
3753
|
try {
|
|
3712
3754
|
data = JSON.parse(r.data);
|
|
3713
3755
|
} catch {
|
|
3714
|
-
|
|
3756
|
+
blocked.invalidData++;
|
|
3757
|
+
continue;
|
|
3758
|
+
}
|
|
3759
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
3760
|
+
blocked.invalidData++;
|
|
3715
3761
|
continue;
|
|
3716
3762
|
}
|
|
3717
3763
|
const columns = schema.columns.map(resolveColumn);
|
|
@@ -3732,20 +3778,32 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3732
3778
|
r.updated_at,
|
|
3733
3779
|
...colIds.map((c) => colValues[c])
|
|
3734
3780
|
];
|
|
3735
|
-
|
|
3736
|
-
`INSERT INTO "${tbl}" (${allCols.join(", ")}) VALUES (${placeholders})`,
|
|
3737
|
-
|
|
3738
|
-
);
|
|
3739
|
-
migrated++;
|
|
3781
|
+
prepared.push({
|
|
3782
|
+
sql: `INSERT INTO "${tbl}" (${allCols.join(", ")}) VALUES (${placeholders})`,
|
|
3783
|
+
params
|
|
3784
|
+
});
|
|
3740
3785
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3786
|
+
const blockedCount = blocked.missingSchema + blocked.invalidData + blocked.targetConflict;
|
|
3787
|
+
if (blockedCount > 0) {
|
|
3788
|
+
console.error(
|
|
3789
|
+
`[RecordRoom] Legacy records migration blocked; no records migrated and the records table was retained for retry (${blockedCount} blocked: ${blocked.missingSchema} missing schema, ${blocked.invalidData} invalid data, ${blocked.targetConflict} target conflict)`
|
|
3744
3790
|
);
|
|
3791
|
+
return;
|
|
3745
3792
|
}
|
|
3746
|
-
this.
|
|
3793
|
+
this.state.storage.transactionSync(() => {
|
|
3794
|
+
for (const record of prepared) {
|
|
3795
|
+
this.sql.exec(record.sql, ...record.params);
|
|
3796
|
+
}
|
|
3797
|
+
this.sql.exec(`DROP TABLE IF EXISTS records`);
|
|
3798
|
+
});
|
|
3799
|
+
console.log(
|
|
3800
|
+
`[RecordRoom] Migrated ${prepared.length} records from document-mode; legacy records table removed`
|
|
3801
|
+
);
|
|
3747
3802
|
} catch (error) {
|
|
3748
|
-
console.error(
|
|
3803
|
+
console.error(
|
|
3804
|
+
`[RecordRoom] Legacy records migration failed; no records migrated and the records table was retained for retry:`,
|
|
3805
|
+
error
|
|
3806
|
+
);
|
|
3749
3807
|
}
|
|
3750
3808
|
}
|
|
3751
3809
|
// ============================================================================
|
|
@@ -3798,8 +3856,7 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3798
3856
|
userImageUrl,
|
|
3799
3857
|
role: registeredUser.role,
|
|
3800
3858
|
subscriptions: [],
|
|
3801
|
-
yjsSubscriptions: []
|
|
3802
|
-
yjsClientId: this.nextYjsClientId++
|
|
3859
|
+
yjsSubscriptions: []
|
|
3803
3860
|
};
|
|
3804
3861
|
this.send(ws, { type: MSG.USER_INFO, payload: registeredUser });
|
|
3805
3862
|
const totalMs = Date.now() - timing.fetchStart;
|
|
@@ -3814,8 +3871,7 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3814
3871
|
userEmail: "",
|
|
3815
3872
|
role: ROLE_ANONYMOUS,
|
|
3816
3873
|
subscriptions: [],
|
|
3817
|
-
yjsSubscriptions: []
|
|
3818
|
-
yjsClientId: this.nextYjsClientId++
|
|
3874
|
+
yjsSubscriptions: []
|
|
3819
3875
|
};
|
|
3820
3876
|
this.send(ws, {
|
|
3821
3877
|
type: MSG.USER_INFO,
|
|
@@ -3884,12 +3940,12 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3884
3940
|
try {
|
|
3885
3941
|
const attachment = ws.deserializeAttachment();
|
|
3886
3942
|
if (attachment) {
|
|
3887
|
-
|
|
3943
|
+
handleYjsDisconnect(this.createYjsContext(), ws, attachment);
|
|
3888
3944
|
}
|
|
3889
3945
|
} catch {
|
|
3890
3946
|
}
|
|
3891
3947
|
}
|
|
3892
|
-
async webSocketError(
|
|
3948
|
+
async webSocketError(_ws, error) {
|
|
3893
3949
|
console.error(`[RecordRoom] webSocketError:`, error);
|
|
3894
3950
|
}
|
|
3895
3951
|
// ============================================================================
|
|
@@ -3932,7 +3988,7 @@ var RecordRoom = class extends BaseRoom {
|
|
|
3932
3988
|
await handleYjsJoin(yjsCtx, ws, attachment, payload);
|
|
3933
3989
|
break;
|
|
3934
3990
|
case MSG.YJS_LEAVE:
|
|
3935
|
-
handleYjsLeave(ws, attachment, payload);
|
|
3991
|
+
handleYjsLeave(yjsCtx, ws, attachment, payload);
|
|
3936
3992
|
break;
|
|
3937
3993
|
case MSG.LIST_SCHEMAS:
|
|
3938
3994
|
this.handleListSchemas(ws);
|
|
@@ -4091,7 +4147,7 @@ var YjsRoom = class extends BaseRoom {
|
|
|
4091
4147
|
}
|
|
4092
4148
|
onMessage(_ws, _user, _message) {
|
|
4093
4149
|
}
|
|
4094
|
-
onBinaryMessage(ws,
|
|
4150
|
+
onBinaryMessage(ws, _user, data) {
|
|
4095
4151
|
const bytes = new Uint8Array(data);
|
|
4096
4152
|
const decoder = createDecoder(bytes);
|
|
4097
4153
|
const messageType = readVarUint(decoder);
|
|
@@ -4384,7 +4440,7 @@ var GameRoom = class extends BaseRoom {
|
|
|
4384
4440
|
this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown game message type: ${type}` } });
|
|
4385
4441
|
}
|
|
4386
4442
|
}
|
|
4387
|
-
onDisconnect(
|
|
4443
|
+
onDisconnect(_ws, user) {
|
|
4388
4444
|
const player = this.players.get(user.userId);
|
|
4389
4445
|
if (player) {
|
|
4390
4446
|
this.players.delete(user.userId);
|
|
@@ -4692,7 +4748,7 @@ var CanvasRoom = class extends BaseRoom {
|
|
|
4692
4748
|
this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown canvas message type: ${type}` } });
|
|
4693
4749
|
}
|
|
4694
4750
|
}
|
|
4695
|
-
onDisconnect(
|
|
4751
|
+
onDisconnect(_ws, user) {
|
|
4696
4752
|
this.viewports.delete(user.userId);
|
|
4697
4753
|
this.undoStacks.delete(user.userId);
|
|
4698
4754
|
this.redoStacks.delete(user.userId);
|
|
@@ -4886,6 +4942,140 @@ var PresenceRoom = class extends BaseRoom {
|
|
|
4886
4942
|
}
|
|
4887
4943
|
};
|
|
4888
4944
|
|
|
4945
|
+
// src/server/rooms/cron-schedule.ts
|
|
4946
|
+
var TASK_NAME_RE = /^[a-z0-9-]+$/;
|
|
4947
|
+
function validateTask(task) {
|
|
4948
|
+
if (!task.name || typeof task.name !== "string") {
|
|
4949
|
+
throw new Error("CronTask.name is required");
|
|
4950
|
+
}
|
|
4951
|
+
if (task.name.length > 64 || !TASK_NAME_RE.test(task.name)) {
|
|
4952
|
+
throw new Error(
|
|
4953
|
+
`CronTask.name "${task.name}" must be lowercase alphanumeric with hyphens, 1-64 chars`
|
|
4954
|
+
);
|
|
4955
|
+
}
|
|
4956
|
+
const hasInterval = task.intervalMinutes != null;
|
|
4957
|
+
const hasSchedule = task.schedule != null;
|
|
4958
|
+
const hasTimezone = task.timezone != null;
|
|
4959
|
+
if (hasInterval && (hasSchedule || hasTimezone)) {
|
|
4960
|
+
throw new Error(`CronTask "${task.name}" cannot mix intervalMinutes with schedule/timezone`);
|
|
4961
|
+
}
|
|
4962
|
+
if (!hasInterval && !(hasSchedule && hasTimezone)) {
|
|
4963
|
+
throw new Error(
|
|
4964
|
+
`CronTask "${task.name}" must declare either intervalMinutes or schedule+timezone`
|
|
4965
|
+
);
|
|
4966
|
+
}
|
|
4967
|
+
if (hasInterval && (!Number.isInteger(task.intervalMinutes) || task.intervalMinutes < 1 || task.intervalMinutes > 10080)) {
|
|
4968
|
+
throw new Error(`CronTask "${task.name}" intervalMinutes must be an integer 1..10080`);
|
|
4969
|
+
}
|
|
4970
|
+
if (hasSchedule) {
|
|
4971
|
+
parseCronExpression(task.schedule);
|
|
4972
|
+
if (typeof task.timezone !== "string" || task.timezone.length === 0) {
|
|
4973
|
+
throw new Error(`CronTask "${task.name}" timezone must be a non-empty IANA string`);
|
|
4974
|
+
}
|
|
4975
|
+
try {
|
|
4976
|
+
new Intl.DateTimeFormat("en-US", { timeZone: task.timezone });
|
|
4977
|
+
} catch {
|
|
4978
|
+
throw new Error(
|
|
4979
|
+
`CronTask "${task.name}" timezone "${task.timezone}" is not a valid IANA timezone`
|
|
4980
|
+
);
|
|
4981
|
+
}
|
|
4982
|
+
}
|
|
4983
|
+
return task;
|
|
4984
|
+
}
|
|
4985
|
+
function parseCronExpression(expression) {
|
|
4986
|
+
const fields = expression.trim().split(/\s+/);
|
|
4987
|
+
if (fields.length !== 5) {
|
|
4988
|
+
throw new Error(
|
|
4989
|
+
`Cron expression must have exactly 5 fields, got ${fields.length}: "${expression}"`
|
|
4990
|
+
);
|
|
4991
|
+
}
|
|
4992
|
+
const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
|
|
4993
|
+
return {
|
|
4994
|
+
minute: parseField(minute, 0, 59, "minute"),
|
|
4995
|
+
hour: parseField(hour, 0, 23, "hour"),
|
|
4996
|
+
dayOfMonth: parseField(dayOfMonth, 1, 31, "day-of-month"),
|
|
4997
|
+
month: parseField(month, 1, 12, "month"),
|
|
4998
|
+
dayOfWeek: parseField(dayOfWeek, 0, 6, "day-of-week")
|
|
4999
|
+
};
|
|
5000
|
+
}
|
|
5001
|
+
function parseField(field, min, max, label) {
|
|
5002
|
+
const result = /* @__PURE__ */ new Set();
|
|
5003
|
+
for (const part of field.split(",")) {
|
|
5004
|
+
const value = part.trim();
|
|
5005
|
+
if (value === "*") {
|
|
5006
|
+
for (let i = min; i <= max; i++) result.add(i);
|
|
5007
|
+
continue;
|
|
5008
|
+
}
|
|
5009
|
+
const allStep = value.match(/^\*\/(\d+)$/);
|
|
5010
|
+
if (allStep) {
|
|
5011
|
+
const step = Number.parseInt(allStep[1], 10);
|
|
5012
|
+
if (step <= 0) throw new Error(`Cron ${label} step must be positive: "${value}"`);
|
|
5013
|
+
for (let i = min; i <= max; i += step) result.add(i);
|
|
5014
|
+
continue;
|
|
5015
|
+
}
|
|
5016
|
+
const range = value.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
|
|
5017
|
+
if (range) {
|
|
5018
|
+
const start = Number.parseInt(range[1], 10);
|
|
5019
|
+
const end = Number.parseInt(range[2], 10);
|
|
5020
|
+
const step = range[3] ? Number.parseInt(range[3], 10) : 1;
|
|
5021
|
+
if (start < min || end > max || start > end || step <= 0) {
|
|
5022
|
+
throw new Error(`Cron ${label} range out of bounds: "${value}" (${min}..${max})`);
|
|
5023
|
+
}
|
|
5024
|
+
for (let i = start; i <= end; i += step) result.add(i);
|
|
5025
|
+
continue;
|
|
5026
|
+
}
|
|
5027
|
+
const literal = Number.parseInt(value, 10);
|
|
5028
|
+
if (Number.isNaN(literal) || literal < min || literal > max || String(literal) !== value) {
|
|
5029
|
+
throw new Error(`Cron ${label} value invalid: "${value}" (${min}..${max})`);
|
|
5030
|
+
}
|
|
5031
|
+
result.add(literal);
|
|
5032
|
+
}
|
|
5033
|
+
if (result.size === 0) {
|
|
5034
|
+
throw new Error(`Cron ${label} field "${field}" produced no valid values`);
|
|
5035
|
+
}
|
|
5036
|
+
return result;
|
|
5037
|
+
}
|
|
5038
|
+
function nextCronFire(expression, timezone, from) {
|
|
5039
|
+
const parsed = parseCronExpression(expression);
|
|
5040
|
+
const start = Math.floor(from.getTime() / 6e4) * 6e4 + 6e4;
|
|
5041
|
+
const limit = start + 366 * 24 * 60 * 6e4;
|
|
5042
|
+
for (let timestamp = start; timestamp < limit; timestamp += 6e4) {
|
|
5043
|
+
const candidate = new Date(timestamp);
|
|
5044
|
+
if (cronMatches(parsed, timezone, candidate)) return candidate;
|
|
5045
|
+
}
|
|
5046
|
+
return null;
|
|
5047
|
+
}
|
|
5048
|
+
function cronMatches(parsed, timezone, date) {
|
|
5049
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
5050
|
+
timeZone: timezone,
|
|
5051
|
+
year: "numeric",
|
|
5052
|
+
month: "numeric",
|
|
5053
|
+
day: "numeric",
|
|
5054
|
+
hour: "numeric",
|
|
5055
|
+
minute: "numeric",
|
|
5056
|
+
weekday: "short",
|
|
5057
|
+
hour12: false
|
|
5058
|
+
});
|
|
5059
|
+
const parts = Object.fromEntries(
|
|
5060
|
+
formatter.formatToParts(date).map((part) => [part.type, part.value])
|
|
5061
|
+
);
|
|
5062
|
+
const minute = Number.parseInt(parts.minute, 10);
|
|
5063
|
+
const hour = Number.parseInt(parts.hour, 10) % 24;
|
|
5064
|
+
const dayOfMonth = Number.parseInt(parts.day, 10);
|
|
5065
|
+
const month = Number.parseInt(parts.month, 10);
|
|
5066
|
+
const weekday = {
|
|
5067
|
+
Sun: 0,
|
|
5068
|
+
Mon: 1,
|
|
5069
|
+
Tue: 2,
|
|
5070
|
+
Wed: 3,
|
|
5071
|
+
Thu: 4,
|
|
5072
|
+
Fri: 5,
|
|
5073
|
+
Sat: 6
|
|
5074
|
+
};
|
|
5075
|
+
const dayOfWeek = weekday[parts.weekday] ?? 0;
|
|
5076
|
+
return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
|
|
5077
|
+
}
|
|
5078
|
+
|
|
4889
5079
|
// src/server/rooms/cron-room.ts
|
|
4890
5080
|
var CRON_WRITE_TYPES = /* @__PURE__ */ new Set([
|
|
4891
5081
|
MSG.CRON_TRIGGER,
|
|
@@ -4926,10 +5116,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
4926
5116
|
)
|
|
4927
5117
|
`);
|
|
4928
5118
|
for (const task of this.tasks) {
|
|
4929
|
-
const existing = this.sql.exec(
|
|
4930
|
-
`SELECT name FROM cron_tasks WHERE name = ?`,
|
|
4931
|
-
task.name
|
|
4932
|
-
).toArray();
|
|
5119
|
+
const existing = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, task.name).toArray();
|
|
4933
5120
|
if (existing.length === 0) {
|
|
4934
5121
|
this.sql.exec(
|
|
4935
5122
|
`INSERT INTO cron_tasks (name, interval_minutes, schedule, timezone, paused)
|
|
@@ -5000,7 +5187,12 @@ var CronRoom = class extends BaseRoom {
|
|
|
5000
5187
|
this.sendTo(ws, { type: MSG.ERROR, payload: { error: "Missing taskName" } });
|
|
5001
5188
|
return;
|
|
5002
5189
|
}
|
|
5003
|
-
await this.executeTask(taskName)
|
|
5190
|
+
if (!await this.executeTask(taskName)) {
|
|
5191
|
+
this.sendTo(ws, {
|
|
5192
|
+
type: MSG.ERROR,
|
|
5193
|
+
payload: { error: `Unknown cron task: ${taskName}` }
|
|
5194
|
+
});
|
|
5195
|
+
}
|
|
5004
5196
|
break;
|
|
5005
5197
|
}
|
|
5006
5198
|
case MSG.CRON_PAUSE: {
|
|
@@ -5032,7 +5224,10 @@ var CronRoom = class extends BaseRoom {
|
|
|
5032
5224
|
break;
|
|
5033
5225
|
}
|
|
5034
5226
|
default:
|
|
5035
|
-
this.sendTo(ws, {
|
|
5227
|
+
this.sendTo(ws, {
|
|
5228
|
+
type: MSG.ERROR,
|
|
5229
|
+
payload: { error: `Unknown cron message type: ${type}` }
|
|
5230
|
+
});
|
|
5036
5231
|
}
|
|
5037
5232
|
}
|
|
5038
5233
|
async onAlarm() {
|
|
@@ -5052,6 +5247,8 @@ var CronRoom = class extends BaseRoom {
|
|
|
5052
5247
|
// Task Execution
|
|
5053
5248
|
// ==========================================================================
|
|
5054
5249
|
async executeTask(taskName) {
|
|
5250
|
+
const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
|
|
5251
|
+
if (!taskRow) return false;
|
|
5055
5252
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5056
5253
|
const start = Date.now();
|
|
5057
5254
|
let success = true;
|
|
@@ -5076,21 +5273,18 @@ var CronRoom = class extends BaseRoom {
|
|
|
5076
5273
|
error ?? null,
|
|
5077
5274
|
completedAt
|
|
5078
5275
|
);
|
|
5079
|
-
const
|
|
5080
|
-
|
|
5276
|
+
const nextRunAt = computeNextRunAt(taskRow, new Date(completedAt));
|
|
5277
|
+
this.sql.exec(
|
|
5278
|
+
`UPDATE cron_tasks SET last_run_at = ?, next_run_at = ? WHERE name = ?`,
|
|
5279
|
+
completedAt,
|
|
5280
|
+
nextRunAt,
|
|
5081
5281
|
taskName
|
|
5082
|
-
)
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
`UPDATE cron_tasks SET last_run_at = ?, next_run_at = ? WHERE name = ?`,
|
|
5087
|
-
completedAt,
|
|
5088
|
-
nextRunAt,
|
|
5089
|
-
taskName
|
|
5090
|
-
);
|
|
5091
|
-
}
|
|
5092
|
-
this.sql.exec(`DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`);
|
|
5282
|
+
);
|
|
5283
|
+
this.sql.exec(
|
|
5284
|
+
`DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
|
|
5285
|
+
);
|
|
5093
5286
|
this.broadcastStatus();
|
|
5287
|
+
return true;
|
|
5094
5288
|
}
|
|
5095
5289
|
// ==========================================================================
|
|
5096
5290
|
// Scheduling
|
|
@@ -5139,10 +5333,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
5139
5333
|
});
|
|
5140
5334
|
}
|
|
5141
5335
|
getRecentHistory(limit) {
|
|
5142
|
-
return this.sql.exec(
|
|
5143
|
-
`SELECT * FROM cron_history ORDER BY id DESC LIMIT ?`,
|
|
5144
|
-
limit
|
|
5145
|
-
).toArray().map((row) => {
|
|
5336
|
+
return this.sql.exec(`SELECT * FROM cron_history ORDER BY id DESC LIMIT ?`, limit).toArray().map((row) => {
|
|
5146
5337
|
const r = row;
|
|
5147
5338
|
return {
|
|
5148
5339
|
taskName: r.task_name,
|
|
@@ -5164,100 +5355,6 @@ var CronRoom = class extends BaseRoom {
|
|
|
5164
5355
|
});
|
|
5165
5356
|
}
|
|
5166
5357
|
};
|
|
5167
|
-
var TASK_NAME_RE = /^[a-z0-9-]+$/;
|
|
5168
|
-
function validateTask(task) {
|
|
5169
|
-
if (!task.name || typeof task.name !== "string") {
|
|
5170
|
-
throw new Error(`CronTask.name is required`);
|
|
5171
|
-
}
|
|
5172
|
-
if (task.name.length > 64 || !TASK_NAME_RE.test(task.name)) {
|
|
5173
|
-
throw new Error(
|
|
5174
|
-
`CronTask.name "${task.name}" must be lowercase alphanumeric with hyphens, 1-64 chars`
|
|
5175
|
-
);
|
|
5176
|
-
}
|
|
5177
|
-
const hasInterval = task.intervalMinutes != null;
|
|
5178
|
-
const hasSchedule = task.schedule != null;
|
|
5179
|
-
const hasTimezone = task.timezone != null;
|
|
5180
|
-
if (hasInterval && (hasSchedule || hasTimezone)) {
|
|
5181
|
-
throw new Error(
|
|
5182
|
-
`CronTask "${task.name}" cannot mix intervalMinutes with schedule/timezone`
|
|
5183
|
-
);
|
|
5184
|
-
}
|
|
5185
|
-
if (!hasInterval && !(hasSchedule && hasTimezone)) {
|
|
5186
|
-
throw new Error(
|
|
5187
|
-
`CronTask "${task.name}" must declare either intervalMinutes or schedule+timezone`
|
|
5188
|
-
);
|
|
5189
|
-
}
|
|
5190
|
-
if (hasInterval) {
|
|
5191
|
-
if (!Number.isInteger(task.intervalMinutes) || task.intervalMinutes < 1 || task.intervalMinutes > 10080) {
|
|
5192
|
-
throw new Error(
|
|
5193
|
-
`CronTask "${task.name}" intervalMinutes must be an integer 1..10080`
|
|
5194
|
-
);
|
|
5195
|
-
}
|
|
5196
|
-
}
|
|
5197
|
-
if (hasSchedule) {
|
|
5198
|
-
parseCronExpression(task.schedule);
|
|
5199
|
-
if (typeof task.timezone !== "string" || task.timezone.length === 0) {
|
|
5200
|
-
throw new Error(`CronTask "${task.name}" timezone must be a non-empty IANA string`);
|
|
5201
|
-
}
|
|
5202
|
-
try {
|
|
5203
|
-
new Intl.DateTimeFormat("en-US", { timeZone: task.timezone });
|
|
5204
|
-
} catch {
|
|
5205
|
-
throw new Error(`CronTask "${task.name}" timezone "${task.timezone}" is not a valid IANA timezone`);
|
|
5206
|
-
}
|
|
5207
|
-
}
|
|
5208
|
-
return task;
|
|
5209
|
-
}
|
|
5210
|
-
function parseCronExpression(expression) {
|
|
5211
|
-
const fields = expression.trim().split(/\s+/);
|
|
5212
|
-
if (fields.length !== 5) {
|
|
5213
|
-
throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expression}"`);
|
|
5214
|
-
}
|
|
5215
|
-
const [cMin, cHour, cDom, cMonth, cDow] = fields;
|
|
5216
|
-
return {
|
|
5217
|
-
minute: parseField(cMin, 0, 59, "minute"),
|
|
5218
|
-
hour: parseField(cHour, 0, 23, "hour"),
|
|
5219
|
-
dayOfMonth: parseField(cDom, 1, 31, "day-of-month"),
|
|
5220
|
-
month: parseField(cMonth, 1, 12, "month"),
|
|
5221
|
-
dayOfWeek: parseField(cDow, 0, 6, "day-of-week")
|
|
5222
|
-
};
|
|
5223
|
-
}
|
|
5224
|
-
function parseField(field, min, max, label) {
|
|
5225
|
-
const result = /* @__PURE__ */ new Set();
|
|
5226
|
-
for (const part of field.split(",")) {
|
|
5227
|
-
const trimmed = part.trim();
|
|
5228
|
-
if (trimmed === "*") {
|
|
5229
|
-
for (let i = min; i <= max; i++) result.add(i);
|
|
5230
|
-
continue;
|
|
5231
|
-
}
|
|
5232
|
-
const allStep = trimmed.match(/^\*\/(\d+)$/);
|
|
5233
|
-
if (allStep) {
|
|
5234
|
-
const step = parseInt(allStep[1], 10);
|
|
5235
|
-
if (step <= 0) throw new Error(`Cron ${label} step must be positive: "${trimmed}"`);
|
|
5236
|
-
for (let i = min; i <= max; i += step) result.add(i);
|
|
5237
|
-
continue;
|
|
5238
|
-
}
|
|
5239
|
-
const rangeMatch = trimmed.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
|
|
5240
|
-
if (rangeMatch) {
|
|
5241
|
-
const start = parseInt(rangeMatch[1], 10);
|
|
5242
|
-
const end = parseInt(rangeMatch[2], 10);
|
|
5243
|
-
const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1;
|
|
5244
|
-
if (start < min || end > max || start > end || step <= 0) {
|
|
5245
|
-
throw new Error(`Cron ${label} range out of bounds: "${trimmed}" (${min}..${max})`);
|
|
5246
|
-
}
|
|
5247
|
-
for (let i = start; i <= end; i += step) result.add(i);
|
|
5248
|
-
continue;
|
|
5249
|
-
}
|
|
5250
|
-
const num = parseInt(trimmed, 10);
|
|
5251
|
-
if (isNaN(num) || num < min || num > max || String(num) !== trimmed) {
|
|
5252
|
-
throw new Error(`Cron ${label} value invalid: "${trimmed}" (${min}..${max})`);
|
|
5253
|
-
}
|
|
5254
|
-
result.add(num);
|
|
5255
|
-
}
|
|
5256
|
-
if (result.size === 0) {
|
|
5257
|
-
throw new Error(`Cron ${label} field "${field}" produced no valid values`);
|
|
5258
|
-
}
|
|
5259
|
-
return result;
|
|
5260
|
-
}
|
|
5261
5358
|
function computeNextRunAt(task, from) {
|
|
5262
5359
|
if (task.interval_minutes) {
|
|
5263
5360
|
return new Date(from.getTime() + task.interval_minutes * 60 * 1e3).toISOString();
|
|
@@ -5268,40 +5365,6 @@ function computeNextRunAt(task, from) {
|
|
|
5268
5365
|
}
|
|
5269
5366
|
return null;
|
|
5270
5367
|
}
|
|
5271
|
-
function nextCronFire(expression, timezone, from) {
|
|
5272
|
-
const parsed = parseCronExpression(expression);
|
|
5273
|
-
const start = new Date(Math.floor(from.getTime() / 6e4) * 6e4 + 6e4);
|
|
5274
|
-
const limit = new Date(start.getTime() + 366 * 24 * 60 * 6e4);
|
|
5275
|
-
for (let t = start.getTime(); t < limit.getTime(); t += 6e4) {
|
|
5276
|
-
const candidate = new Date(t);
|
|
5277
|
-
if (cronMatches(parsed, timezone, candidate)) {
|
|
5278
|
-
return candidate;
|
|
5279
|
-
}
|
|
5280
|
-
}
|
|
5281
|
-
return null;
|
|
5282
|
-
}
|
|
5283
|
-
function cronMatches(parsed, timezone, date) {
|
|
5284
|
-
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
5285
|
-
timeZone: timezone,
|
|
5286
|
-
year: "numeric",
|
|
5287
|
-
month: "numeric",
|
|
5288
|
-
day: "numeric",
|
|
5289
|
-
hour: "numeric",
|
|
5290
|
-
minute: "numeric",
|
|
5291
|
-
weekday: "short",
|
|
5292
|
-
hour12: false
|
|
5293
|
-
});
|
|
5294
|
-
const parts = Object.fromEntries(
|
|
5295
|
-
formatter.formatToParts(date).map((p) => [p.type, p.value])
|
|
5296
|
-
);
|
|
5297
|
-
const minute = parseInt(parts.minute, 10);
|
|
5298
|
-
const hour = parseInt(parts.hour, 10) % 24;
|
|
5299
|
-
const dayOfMonth = parseInt(parts.day, 10);
|
|
5300
|
-
const month = parseInt(parts.month, 10);
|
|
5301
|
-
const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
5302
|
-
const dayOfWeek = weekdayMap[parts.weekday] ?? 0;
|
|
5303
|
-
return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
|
|
5304
|
-
}
|
|
5305
5368
|
|
|
5306
5369
|
// src/server/rooms/job-room.ts
|
|
5307
5370
|
var RUNNING_STALE_MS = 16 * 60 * 1e3;
|
|
@@ -5414,6 +5477,7 @@ var JobRoom = class extends BaseRoom {
|
|
|
5414
5477
|
}
|
|
5415
5478
|
async onAlarm() {
|
|
5416
5479
|
this.ensureInitialized();
|
|
5480
|
+
this.recoverStuckRunning();
|
|
5417
5481
|
await this.drainDueJobs();
|
|
5418
5482
|
this.pruneExpired();
|
|
5419
5483
|
this.scheduleNextAlarm();
|
|
@@ -5716,22 +5780,36 @@ var JobRoom = class extends BaseRoom {
|
|
|
5716
5780
|
// Scheduling / recovery / pruning
|
|
5717
5781
|
// ==========================================================================
|
|
5718
5782
|
scheduleNextAlarm() {
|
|
5719
|
-
const
|
|
5783
|
+
const queued = this.sql.exec(
|
|
5720
5784
|
`SELECT next_run_at FROM jobs
|
|
5721
5785
|
WHERE status = 'queued'
|
|
5722
5786
|
ORDER BY (next_run_at IS NULL) ASC, next_run_at ASC
|
|
5723
5787
|
LIMIT 1`
|
|
5724
5788
|
).toArray()[0];
|
|
5725
|
-
if (!row) return;
|
|
5726
5789
|
const now = Date.now();
|
|
5727
|
-
const
|
|
5728
|
-
|
|
5790
|
+
const targets = [];
|
|
5791
|
+
if (queued) {
|
|
5792
|
+
const queuedAt = queued.next_run_at ? new Date(queued.next_run_at).getTime() : now;
|
|
5793
|
+
targets.push(Number.isFinite(queuedAt) ? Math.max(now, queuedAt) : now);
|
|
5794
|
+
}
|
|
5795
|
+
const running = this.sql.exec(
|
|
5796
|
+
`SELECT started_at FROM jobs
|
|
5797
|
+
WHERE status = 'running'
|
|
5798
|
+
ORDER BY started_at ASC
|
|
5799
|
+
LIMIT 1`
|
|
5800
|
+
).toArray()[0];
|
|
5801
|
+
if (running) {
|
|
5802
|
+
const startedAt = running.started_at ? new Date(running.started_at).getTime() : Number.NaN;
|
|
5803
|
+
targets.push(Number.isFinite(startedAt) ? Math.max(now, startedAt + RUNNING_STALE_MS) : now);
|
|
5804
|
+
}
|
|
5805
|
+
if (targets.length === 0) return;
|
|
5806
|
+
this.state.storage.setAlarm(Math.max(Math.min(...targets), now + 50));
|
|
5729
5807
|
}
|
|
5730
5808
|
recoverStuckRunning() {
|
|
5731
5809
|
const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
|
|
5732
5810
|
const stuck = this.sql.exec(
|
|
5733
5811
|
`SELECT id, attempts, max_attempts FROM jobs
|
|
5734
|
-
WHERE status = 'running' AND started_at
|
|
5812
|
+
WHERE status = 'running' AND (started_at IS NULL OR started_at <= ?)`,
|
|
5735
5813
|
cutoff
|
|
5736
5814
|
).toArray();
|
|
5737
5815
|
for (const r of stuck) {
|
|
@@ -6985,6 +7063,16 @@ function createDeepSpaceAuth(config) {
|
|
|
6985
7063
|
database: config.database,
|
|
6986
7064
|
baseURL: config.baseURL,
|
|
6987
7065
|
secret: config.secret,
|
|
7066
|
+
// Login is a one-time human act; everything after it is agents minting
|
|
7067
|
+
// short-lived JWTs from this session. 30 days idle (vs the 7-day
|
|
7068
|
+
// default) with daily sliding renewal means any agent active within a
|
|
7069
|
+
// month keeps the session alive indefinitely — the human re-logs-in
|
|
7070
|
+
// only after a full month of zero activity. Short-lived access stays
|
|
7071
|
+
// short: CLI JWTs expire in minutes (auth-worker /api/auth/token).
|
|
7072
|
+
session: {
|
|
7073
|
+
expiresIn: 60 * 60 * 24 * 30,
|
|
7074
|
+
updateAge: 60 * 60 * 24
|
|
7075
|
+
},
|
|
6988
7076
|
emailAndPassword: {
|
|
6989
7077
|
enabled: config.emailAndPassword ?? true
|
|
6990
7078
|
},
|
|
@@ -7013,9 +7101,41 @@ function createDeepSpaceAuth(config) {
|
|
|
7013
7101
|
}
|
|
7014
7102
|
}
|
|
7015
7103
|
} : {},
|
|
7104
|
+
// Observer hook for new-user creation (see DeepSpaceAuthConfig.onUserCreated).
|
|
7105
|
+
// Two-layer wiring guard — be honest about what each layer catches:
|
|
7106
|
+
// 1. buildDatabaseHooks's return type is pinned to
|
|
7107
|
+
// BetterAuthOptions['databaseHooks'], so a better-auth upgrade that
|
|
7108
|
+
// renames/reshapes the option fails tsc.
|
|
7109
|
+
// 2. A LOCAL typo of this key would still compile (betterAuth's generic
|
|
7110
|
+
// options parameter defeats excess-property checking even for plain
|
|
7111
|
+
// keys) — that case is caught by the runtime assertions in
|
|
7112
|
+
// __tests__/betterAuth-hooks.test.ts, which read the wired hook back
|
|
7113
|
+
// off auth.options. Don't rename the key without running them.
|
|
7114
|
+
databaseHooks: buildDatabaseHooks(config.onUserCreated),
|
|
7016
7115
|
plugins: [organization(), twoFactor()]
|
|
7017
7116
|
});
|
|
7018
7117
|
}
|
|
7118
|
+
function buildDatabaseHooks(onUserCreated) {
|
|
7119
|
+
if (!onUserCreated) return void 0;
|
|
7120
|
+
return {
|
|
7121
|
+
user: {
|
|
7122
|
+
create: {
|
|
7123
|
+
after: async (user, ctx) => {
|
|
7124
|
+
try {
|
|
7125
|
+
await onUserCreated(
|
|
7126
|
+
{ id: user.id, email: user.email, name: user.name },
|
|
7127
|
+
// Forward BOTH request and standalone headers — some better-auth
|
|
7128
|
+
// entry points populate ctx.headers without a request.
|
|
7129
|
+
ctx ? { request: ctx.request, headers: ctx.headers ?? void 0 } : null
|
|
7130
|
+
);
|
|
7131
|
+
} catch (err) {
|
|
7132
|
+
console.error("[deepspace] onUserCreated hook failed (signup unaffected)", err);
|
|
7133
|
+
}
|
|
7134
|
+
}
|
|
7135
|
+
}
|
|
7136
|
+
}
|
|
7137
|
+
};
|
|
7138
|
+
}
|
|
7019
7139
|
var cachedAppleSecret = null;
|
|
7020
7140
|
async function generateAppleClientSecret(apple) {
|
|
7021
7141
|
const now = Math.floor(Date.now() / 1e3);
|
|
@@ -7172,6 +7292,7 @@ export {
|
|
|
7172
7292
|
AI_CHATS_SCHEMA,
|
|
7173
7293
|
AI_MESSAGES_SCHEMA,
|
|
7174
7294
|
ALLOWED_BINDING_TYPES,
|
|
7295
|
+
APP_ID_RE,
|
|
7175
7296
|
APP_NAME_RULES,
|
|
7176
7297
|
AUTO_PROVISIONABLE_TYPES,
|
|
7177
7298
|
AUTO_PROVISION_SENTINEL,
|
|
@@ -7189,6 +7310,7 @@ export {
|
|
|
7189
7310
|
DEFAULT_CONTEXT_CONFIG,
|
|
7190
7311
|
DEFAULT_DO_MANIFEST,
|
|
7191
7312
|
DEFAULT_QUERY_LIMIT,
|
|
7313
|
+
DEPLOYED_APPS_DEFAULTS,
|
|
7192
7314
|
DIRECTORY_SCHEMAS,
|
|
7193
7315
|
GLOBAL_DO_TYPES,
|
|
7194
7316
|
GLOBAL_DO_TYPE_NAMES,
|
|
@@ -7198,10 +7320,13 @@ export {
|
|
|
7198
7320
|
PresenceRoom,
|
|
7199
7321
|
REACTIONS_SCHEMA,
|
|
7200
7322
|
READ_RECEIPTS_SCHEMA,
|
|
7323
|
+
REGISTRY_INSTANCE,
|
|
7201
7324
|
RESERVED_BINDING_NAMES,
|
|
7202
7325
|
RESERVED_COLLECTION_NAMES,
|
|
7203
7326
|
RecordRoom,
|
|
7204
7327
|
RefundError,
|
|
7328
|
+
RegistryClientError,
|
|
7329
|
+
STRICT_APP_ID_RE,
|
|
7205
7330
|
SYSTEM_COLLECTION_SCHEMAS,
|
|
7206
7331
|
SYSTEM_MANAGED_COLUMNS,
|
|
7207
7332
|
SchemaRegistry,
|
|
@@ -7224,6 +7349,7 @@ export {
|
|
|
7224
7349
|
buildCronContext,
|
|
7225
7350
|
buildTableSelect,
|
|
7226
7351
|
buildUiParts,
|
|
7352
|
+
callRegistry,
|
|
7227
7353
|
canCreate,
|
|
7228
7354
|
canDelete,
|
|
7229
7355
|
canRead,
|
|
@@ -7278,10 +7404,13 @@ export {
|
|
|
7278
7404
|
lintSchema,
|
|
7279
7405
|
loadMessages,
|
|
7280
7406
|
makeDefaultSummarizer,
|
|
7407
|
+
matchOrigin,
|
|
7281
7408
|
meterAi,
|
|
7282
7409
|
meterUsage,
|
|
7283
7410
|
meterVectorize,
|
|
7284
7411
|
noopPermissionContext,
|
|
7412
|
+
parseQuotaLimit,
|
|
7413
|
+
platformCorsPatterns,
|
|
7285
7414
|
platformWorkerFetch,
|
|
7286
7415
|
prepareMessagesWithCompaction,
|
|
7287
7416
|
priceBindingUsageEvent,
|
|
@@ -7290,6 +7419,8 @@ export {
|
|
|
7290
7419
|
recordMatchesSubscription,
|
|
7291
7420
|
refundInvoice,
|
|
7292
7421
|
registerUser,
|
|
7422
|
+
registryErrorJson,
|
|
7423
|
+
registryStub,
|
|
7293
7424
|
requireSubscription,
|
|
7294
7425
|
resolveAppName,
|
|
7295
7426
|
resolveColumn,
|