deepspace 0.3.9 → 0.3.11

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.
Files changed (120) hide show
  1. package/dist/cli.js +96 -18
  2. package/dist/cli.js.map +1 -1
  3. package/dist/index.d.ts +160 -1
  4. package/dist/index.js +283 -126
  5. package/dist/index.js.map +1 -1
  6. package/dist/server.d.ts +172 -2
  7. package/dist/server.js +581 -1
  8. package/dist/server.js.map +1 -1
  9. package/dist/worker.d.ts +252 -2
  10. package/dist/worker.js +588 -1
  11. package/dist/worker.js.map +1 -1
  12. package/features/admin-page/feature.json +29 -0
  13. package/features/admin-page/src/AdminPage.tsx +292 -0
  14. package/features/admin-page/src/admin-schema.ts +20 -0
  15. package/features/ai-chat/feature.json +41 -0
  16. package/features/ai-chat/src/AiChatPage.tsx +692 -0
  17. package/features/ai-chat/src/ChatPanel.tsx +1098 -0
  18. package/features/ai-chat/src/ai-chat-schema.ts +11 -0
  19. package/features/canvas/feature.json +45 -0
  20. package/features/canvas/src/CanvasEditorPage.tsx +87 -0
  21. package/features/canvas/src/CanvasListPage.tsx +202 -0
  22. package/features/canvas/src/canvas-schema.ts +33 -0
  23. package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
  24. package/features/canvas/src/components/CanvasView.tsx +369 -0
  25. package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
  26. package/features/cron/feature.json +33 -0
  27. package/features/cron/src/CronLogPage.tsx +100 -0
  28. package/features/cron/src/cron.ts +25 -0
  29. package/features/docs/feature.json +96 -0
  30. package/features/docs/src/DocsEditorPage.tsx +1012 -0
  31. package/features/docs/src/DocsListPage.tsx +951 -0
  32. package/features/docs/src/DocsOutlinePanel.tsx +68 -0
  33. package/features/docs/src/DocsPresence.tsx +138 -0
  34. package/features/docs/src/InviteDialog.tsx +336 -0
  35. package/features/docs/src/LibrarySidebar.tsx +464 -0
  36. package/features/docs/src/docs-favorites.ts +18 -0
  37. package/features/docs/src/docs-library-types.ts +15 -0
  38. package/features/docs/src/docs-schema.ts +80 -0
  39. package/features/docs/src/docs-ui.css +309 -0
  40. package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
  41. package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
  42. package/features/docs/src/editor/useDocEditor.ts +206 -0
  43. package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
  44. package/features/file-attachments/feature.json +42 -0
  45. package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
  46. package/features/file-attachments/src/attachments-constants.ts +9 -0
  47. package/features/file-attachments/src/attachments-schema.ts +45 -0
  48. package/features/file-attachments/src/useFilePreview.ts +471 -0
  49. package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
  50. package/features/file-manager/feature.json +24 -0
  51. package/features/file-manager/src/FileManagerPage.tsx +330 -0
  52. package/features/integration-test/feature.json +18 -0
  53. package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
  54. package/features/integration-test/src/integration.spec.ts +42 -0
  55. package/features/items/feature.json +33 -0
  56. package/features/items/src/ItemsPage.tsx +325 -0
  57. package/features/items/src/items-constants.ts +10 -0
  58. package/features/items/src/items-schema.ts +27 -0
  59. package/features/kanban/feature.json +18 -0
  60. package/features/kanban/src/Kanban.tsx +283 -0
  61. package/features/landing/feature.json +62 -0
  62. package/features/landing/src/LandingPage.tsx +627 -0
  63. package/features/landing/src/primitives.tsx +389 -0
  64. package/features/landing/src/sections/FAQSection.tsx +110 -0
  65. package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
  66. package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
  67. package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
  68. package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
  69. package/features/landing/src/sections/StatsSection.tsx +31 -0
  70. package/features/landing/src/sections/TeamSection.tsx +86 -0
  71. package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
  72. package/features/landing/src/sections/VideoSection.tsx +71 -0
  73. package/features/leaderboard/feature.json +33 -0
  74. package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
  75. package/features/leaderboard/src/leaderboard-constants.ts +21 -0
  76. package/features/leaderboard/src/leaderboard-schema.ts +36 -0
  77. package/features/messaging/feature.json +111 -0
  78. package/features/messaging/src/ChatMultiPage.tsx +300 -0
  79. package/features/messaging/src/ChatPage.tsx +75 -0
  80. package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
  81. package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
  82. package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
  83. package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
  84. package/features/messaging/src/components/chat/MessageList.tsx +248 -0
  85. package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
  86. package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
  87. package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
  88. package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
  89. package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
  90. package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
  91. package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
  92. package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
  93. package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
  94. package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
  95. package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
  96. package/features/messaging/src/hooks/useChatChannel.ts +64 -0
  97. package/features/messaging/src/hooks/useLongPress.ts +74 -0
  98. package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
  99. package/features/messaging/src/messaging-schema.ts +25 -0
  100. package/features/presence-test/feature.json +21 -0
  101. package/features/presence-test/src/PresenceTestPage.tsx +120 -0
  102. package/features/sidebar/feature.json +19 -0
  103. package/features/sidebar/src/AppSidebar.tsx +558 -0
  104. package/features/tasks/feature.json +33 -0
  105. package/features/tasks/src/TasksPage.tsx +598 -0
  106. package/features/tasks/src/tasks-constants.ts +43 -0
  107. package/features/tasks/src/tasks-schema.ts +56 -0
  108. package/features/testing/feature.json +49 -0
  109. package/features/testing/src/TestPage.tsx +145 -0
  110. package/features/testing/src/cpu-burn-action.ts +51 -0
  111. package/features/testing/src/subrequest-burn-action.ts +53 -0
  112. package/features/testing/src/test-schema.ts +23 -0
  113. package/features/topbar/feature.json +18 -0
  114. package/features/topbar/src/Topbar.tsx +353 -0
  115. package/features/tree/feature.json +26 -0
  116. package/features/tree/src/TreeSidebar.tsx +607 -0
  117. package/features/tree/src/tree-constants.ts +68 -0
  118. package/features/tree/src/tree-hooks.ts +258 -0
  119. package/package.json +4 -2
  120. package/scripts/add-feature.cjs +654 -0
package/dist/server.js CHANGED
@@ -174,6 +174,20 @@ function sanitizeSubpath(raw) {
174
174
  function isKeyWithinPrefix(key, prefix) {
175
175
  return key.startsWith(prefix);
176
176
  }
177
+ var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
178
+ "text/html",
179
+ "application/xhtml+xml",
180
+ "image/svg+xml",
181
+ "application/xml",
182
+ "text/xml",
183
+ "application/javascript",
184
+ "text/javascript",
185
+ "application/ecmascript",
186
+ "text/ecmascript"
187
+ ]);
188
+ function normalizeMimeType(raw) {
189
+ return raw.split(";")[0].trim().toLowerCase();
190
+ }
177
191
  function createScopedR2Handler(config) {
178
192
  const requireAuthForMutations = config.requireAuthForMutations ?? true;
179
193
  return async (request, url, bucket, auth) => {
@@ -263,6 +277,13 @@ async function handleUpload(request, url, bucket, prefix, userId) {
263
277
  fileName = body.name;
264
278
  mimeType = body.mimeType || "application/octet-stream";
265
279
  }
280
+ mimeType = normalizeMimeType(mimeType) || "application/octet-stream";
281
+ if (DANGEROUS_MIME_TYPES.has(mimeType)) {
282
+ return Response.json(
283
+ { error: `Unsupported media type: ${mimeType}` },
284
+ { status: 415, headers: CORS_HEADERS }
285
+ );
286
+ }
266
287
  const requestedKey = url.searchParams.get("key");
267
288
  let key;
268
289
  if (requestedKey !== null) {
@@ -327,6 +348,7 @@ async function handleDownload(bucket, key) {
327
348
  }
328
349
  const headers = new Headers();
329
350
  headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
351
+ headers.set("X-Content-Type-Options", "nosniff");
330
352
  headers.set("Cache-Control", "public, max-age=31536000, immutable");
331
353
  headers.set("Access-Control-Allow-Origin", "*");
332
354
  if (object.customMetadata?.originalName) {
@@ -588,7 +610,7 @@ function createDeepSpaceAuth(config) {
588
610
  }
589
611
 
590
612
  // src/server/utils/cron.ts
591
- var DEFAULT_API_BASE_URL = "https://deepspace-api.eudaimonicincorporated.workers.dev";
613
+ var DEFAULT_API_BASE_URL = "https://api-worker.deep.space";
592
614
  function buildCronContext(env, ownerUserId, roomId = "default") {
593
615
  const hmacSecret = env.INTERNAL_STORAGE_HMAC_SECRET;
594
616
  const apiBase = env.API_BASE_URL || DEFAULT_API_BASE_URL;
@@ -1577,6 +1599,14 @@ var MSG = {
1577
1599
  CRON_PAUSE: "cron.pause",
1578
1600
  CRON_RESUME: "cron.resume",
1579
1601
  CRON_STATUS: "cron.status",
1602
+ // ---- JobRoom ---------------------------------------------------------
1603
+ // Background-job queue with durable, alarm-driven execution. See
1604
+ // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
1605
+ // the room broadcasts JOB_UPDATE for every status transition.
1606
+ JOB_ENQUEUE: "job.enqueue",
1607
+ JOB_CANCEL: "job.cancel",
1608
+ JOB_RETRY: "job.retry",
1609
+ JOB_UPDATE: "job.update",
1580
1610
  // ---- PresenceRoom ----------------------------------------------------
1581
1611
  PRESENCE_SYNC: "presence.sync",
1582
1612
  PRESENCE_JOIN: "presence.join",
@@ -2069,6 +2099,17 @@ var serverBuild = {
2069
2099
  cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
2070
2100
  cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
2071
2101
  cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
2102
+ // Jobs
2103
+ jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
2104
+ jobEnqueued: (job, requestId) => ({
2105
+ type: MSG.JOB_UPDATE,
2106
+ payload: { kind: "enqueued", job, requestId }
2107
+ }),
2108
+ jobProgress: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "progress", job } }),
2109
+ jobSucceeded: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "succeeded", job } }),
2110
+ jobFailed: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "failed", job } }),
2111
+ jobCanceled: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "canceled", job } }),
2112
+ jobRetried: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "retried", job } }),
2072
2113
  // Presence
2073
2114
  presenceSync: (peers) => ({ type: MSG.PRESENCE_SYNC, payload: { peers } }),
2074
2115
  presenceJoin: (peer) => ({ type: MSG.PRESENCE_JOIN, payload: { peer } }),
@@ -5167,6 +5208,543 @@ function cronMatches(parsed, timezone, date) {
5167
5208
  return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
5168
5209
  }
5169
5210
 
5211
+ // src/server/rooms/job-room.ts
5212
+ var RUNNING_STALE_MS = 16 * 60 * 1e3;
5213
+ var DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1e3;
5214
+ var DEFAULT_SNAPSHOT_LIMIT = 100;
5215
+ var JobRoom = class extends BaseRoom {
5216
+ initialized = false;
5217
+ defaultMaxAttempts;
5218
+ retentionMs;
5219
+ snapshotLimit;
5220
+ retryBackoffMs;
5221
+ /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
5222
+ inFlight = /* @__PURE__ */ new Map();
5223
+ /** Set by `ctx.continue` inside `executeJob`; reset before every run. */
5224
+ continueState = null;
5225
+ constructor(state, env, config = {}) {
5226
+ super(state, env);
5227
+ this.defaultMaxAttempts = Math.max(1, config.defaultMaxAttempts ?? 1);
5228
+ this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
5229
+ this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
5230
+ this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
5231
+ }
5232
+ ensureInitialized() {
5233
+ if (this.initialized) return;
5234
+ this.initialized = true;
5235
+ this.sql.exec(`
5236
+ CREATE TABLE IF NOT EXISTS jobs (
5237
+ id TEXT PRIMARY KEY,
5238
+ type TEXT NOT NULL,
5239
+ status TEXT NOT NULL,
5240
+ payload TEXT,
5241
+ result TEXT,
5242
+ error TEXT,
5243
+ progress REAL,
5244
+ progress_message TEXT,
5245
+ checkpoint TEXT,
5246
+ attempts INTEGER NOT NULL DEFAULT 0,
5247
+ max_attempts INTEGER NOT NULL DEFAULT 1,
5248
+ enqueued_at TEXT NOT NULL,
5249
+ started_at TEXT,
5250
+ completed_at TEXT,
5251
+ enqueued_by TEXT,
5252
+ next_run_at TEXT
5253
+ )
5254
+ `);
5255
+ this.sql.exec(
5256
+ `CREATE INDEX IF NOT EXISTS jobs_status_next_run ON jobs (status, next_run_at)`
5257
+ );
5258
+ this.sql.exec(
5259
+ `CREATE INDEX IF NOT EXISTS jobs_completed_at ON jobs (completed_at)`
5260
+ );
5261
+ this.recoverStuckRunning();
5262
+ this.scheduleNextAlarm();
5263
+ }
5264
+ // ==========================================================================
5265
+ // BaseRoom Lifecycle
5266
+ // ==========================================================================
5267
+ async fetch(request) {
5268
+ this.ensureInitialized();
5269
+ return super.fetch(request);
5270
+ }
5271
+ onConnect(ws, user) {
5272
+ this.ensureInitialized();
5273
+ this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
5274
+ return user;
5275
+ }
5276
+ async onMessage(ws, user, message) {
5277
+ this.ensureInitialized();
5278
+ const { type, payload } = message;
5279
+ switch (type) {
5280
+ case MSG.JOB_ENQUEUE: {
5281
+ const requestId = String(payload.requestId ?? "");
5282
+ const jobType = String(payload.type ?? "");
5283
+ if (!jobType) {
5284
+ this.sendTo(ws, serverBuild.error("JOB_ENQUEUE missing type"));
5285
+ return;
5286
+ }
5287
+ const job = this.insertJob({
5288
+ type: jobType,
5289
+ payload: payload.payload,
5290
+ maxAttempts: typeof payload.maxAttempts === "number" ? Math.max(1, Math.floor(payload.maxAttempts)) : void 0,
5291
+ enqueuedBy: user.userId
5292
+ });
5293
+ this.sendTo(ws, serverBuild.jobEnqueued(job, requestId));
5294
+ this.broadcast(serverBuild.jobEnqueued(job), ws);
5295
+ this.scheduleNextAlarm();
5296
+ break;
5297
+ }
5298
+ case MSG.JOB_CANCEL: {
5299
+ const jobId = String(payload.jobId ?? "");
5300
+ if (!jobId) {
5301
+ this.sendTo(ws, serverBuild.error("JOB_CANCEL missing jobId"));
5302
+ return;
5303
+ }
5304
+ this.cancelJob(jobId);
5305
+ break;
5306
+ }
5307
+ case MSG.JOB_RETRY: {
5308
+ const jobId = String(payload.jobId ?? "");
5309
+ if (!jobId) {
5310
+ this.sendTo(ws, serverBuild.error("JOB_RETRY missing jobId"));
5311
+ return;
5312
+ }
5313
+ this.retryJob(jobId);
5314
+ break;
5315
+ }
5316
+ default:
5317
+ this.sendTo(ws, serverBuild.error(`Unknown job message type: ${type}`));
5318
+ }
5319
+ }
5320
+ async onAlarm() {
5321
+ this.ensureInitialized();
5322
+ await this.drainDueJobs();
5323
+ this.pruneExpired();
5324
+ this.scheduleNextAlarm();
5325
+ }
5326
+ // ==========================================================================
5327
+ // HTTP — internal enqueue path for cross-isolate callers
5328
+ // ==========================================================================
5329
+ /**
5330
+ * One internal endpoint, `POST /enqueue`, used by the `enqueueJob`
5331
+ * helper at the bottom of this file. The room is not meant to be
5332
+ * reachable from the public internet; callers route through the app
5333
+ * worker (or call `this.enqueue(...)` directly from a subclass).
5334
+ *
5335
+ * Subclasses overriding `onRequest` should call `super.onRequest(req)`
5336
+ * for paths they don't handle.
5337
+ */
5338
+ async onRequest(request) {
5339
+ this.ensureInitialized();
5340
+ const url = new URL(request.url);
5341
+ if (request.method !== "POST" || !url.pathname.endsWith("/enqueue")) {
5342
+ return new Response("Not Found", { status: 404 });
5343
+ }
5344
+ const body = await request.json().catch(() => null);
5345
+ if (!body || typeof body.type !== "string" || !body.type) {
5346
+ return new Response(JSON.stringify({ error: "type required" }), {
5347
+ status: 400,
5348
+ headers: { "Content-Type": "application/json" }
5349
+ });
5350
+ }
5351
+ const job = this.insertJob({
5352
+ type: body.type,
5353
+ payload: body.payload,
5354
+ maxAttempts: typeof body.maxAttempts === "number" ? Math.max(1, Math.floor(body.maxAttempts)) : void 0,
5355
+ enqueuedBy: typeof body.enqueuedBy === "string" ? body.enqueuedBy : void 0
5356
+ });
5357
+ this.broadcast(serverBuild.jobEnqueued(job));
5358
+ this.scheduleNextAlarm();
5359
+ return new Response(JSON.stringify({ job }), {
5360
+ status: 200,
5361
+ headers: { "Content-Type": "application/json" }
5362
+ });
5363
+ }
5364
+ // ==========================================================================
5365
+ // Public enqueue surface (server-side callers in the same isolate)
5366
+ // ==========================================================================
5367
+ /**
5368
+ * Synchronously persist a new job and arm the alarm. Returns the new
5369
+ * job. This is the in-isolate path — useful for a subclass `onJob`
5370
+ * that wants to chain follow-up work without paying a fetch hop:
5371
+ *
5372
+ * protected async onJob(job: Job, ctx: JobContext) {
5373
+ * // ... do work ...
5374
+ * if (needsFollowup) this.enqueue('followup', { parent: job.id })
5375
+ * }
5376
+ *
5377
+ * Workers code that lives outside the DO isolate (HTTP routes, cron
5378
+ * tasks, server actions) cannot call this directly — they must go
5379
+ * through the `enqueueJob` helper at the bottom of this file, which
5380
+ * routes via the DO's `/enqueue` HTTP endpoint.
5381
+ */
5382
+ enqueue(type, payload, options) {
5383
+ this.ensureInitialized();
5384
+ const job = this.insertJob({
5385
+ type,
5386
+ payload,
5387
+ maxAttempts: options?.maxAttempts,
5388
+ enqueuedBy: options?.enqueuedBy
5389
+ });
5390
+ this.broadcast(serverBuild.jobEnqueued(job));
5391
+ this.scheduleNextAlarm();
5392
+ return job;
5393
+ }
5394
+ // ==========================================================================
5395
+ // Mutation helpers
5396
+ // ==========================================================================
5397
+ insertJob(input) {
5398
+ const id = crypto.randomUUID();
5399
+ const enqueuedAt = (/* @__PURE__ */ new Date()).toISOString();
5400
+ const maxAttempts = Math.max(1, input.maxAttempts ?? this.defaultMaxAttempts);
5401
+ const payloadJson = input.payload === void 0 ? null : safeStringify(input.payload);
5402
+ this.sql.exec(
5403
+ `INSERT INTO jobs (id, type, status, payload, attempts, max_attempts, enqueued_at, enqueued_by, next_run_at)
5404
+ VALUES (?, ?, 'queued', ?, 0, ?, ?, ?, ?)`,
5405
+ id,
5406
+ input.type,
5407
+ payloadJson,
5408
+ maxAttempts,
5409
+ enqueuedAt,
5410
+ input.enqueuedBy ?? null,
5411
+ enqueuedAt
5412
+ );
5413
+ return this.getJobById(id);
5414
+ }
5415
+ cancelJob(jobId) {
5416
+ const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
5417
+ if (!row) return;
5418
+ if (row.status === "succeeded" || row.status === "failed" || row.status === "canceled") {
5419
+ return;
5420
+ }
5421
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
5422
+ this.sql.exec(
5423
+ `UPDATE jobs
5424
+ SET status = 'canceled',
5425
+ completed_at = ?,
5426
+ checkpoint = NULL,
5427
+ next_run_at = NULL
5428
+ WHERE id = ?`,
5429
+ completedAt,
5430
+ jobId
5431
+ );
5432
+ const controller = this.inFlight.get(jobId);
5433
+ if (controller) controller.abort();
5434
+ const job = this.getJobById(jobId);
5435
+ if (job) this.broadcast(serverBuild.jobCanceled(job));
5436
+ }
5437
+ retryJob(jobId) {
5438
+ const row = this.sql.exec(`SELECT status, attempts, max_attempts FROM jobs WHERE id = ?`, jobId).toArray()[0];
5439
+ if (!row) return;
5440
+ if (row.status !== "failed" && row.status !== "canceled") return;
5441
+ const newMax = Math.max(row.max_attempts, row.attempts + 1);
5442
+ this.sql.exec(
5443
+ `UPDATE jobs
5444
+ SET status = 'queued',
5445
+ max_attempts = ?,
5446
+ error = NULL,
5447
+ result = NULL,
5448
+ completed_at = NULL,
5449
+ next_run_at = ?
5450
+ WHERE id = ?`,
5451
+ newMax,
5452
+ (/* @__PURE__ */ new Date()).toISOString(),
5453
+ jobId
5454
+ );
5455
+ const job = this.getJobById(jobId);
5456
+ if (job) this.broadcast(serverBuild.jobRetried(job));
5457
+ this.scheduleNextAlarm();
5458
+ }
5459
+ // ==========================================================================
5460
+ // Alarm / execution
5461
+ // ==========================================================================
5462
+ /**
5463
+ * Drain due jobs in FIFO order. All due jobs share the alarm's
5464
+ * 15-min wall budget (a CF property, not a per-job allocation).
5465
+ */
5466
+ async drainDueJobs() {
5467
+ const cutoff = (/* @__PURE__ */ new Date()).toISOString();
5468
+ while (true) {
5469
+ const row = this.sql.exec(
5470
+ `SELECT * FROM jobs
5471
+ WHERE status = 'queued'
5472
+ AND (next_run_at IS NULL OR next_run_at <= ?)
5473
+ ORDER BY enqueued_at ASC
5474
+ LIMIT 1`,
5475
+ cutoff
5476
+ ).toArray()[0];
5477
+ if (!row) return;
5478
+ await this.executeJob(row);
5479
+ }
5480
+ }
5481
+ async executeJob(row) {
5482
+ const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
5483
+ const attemptsBefore = row.attempts;
5484
+ const attempt = attemptsBefore + 1;
5485
+ this.sql.exec(
5486
+ `UPDATE jobs SET status = 'running', started_at = ?, attempts = ? WHERE id = ?`,
5487
+ startedAt,
5488
+ attempt,
5489
+ row.id
5490
+ );
5491
+ const controller = new AbortController();
5492
+ this.inFlight.set(row.id, controller);
5493
+ this.continueState = null;
5494
+ const job = {
5495
+ ...rowToJobView(row),
5496
+ status: "running",
5497
+ attempts: attempt,
5498
+ startedAt
5499
+ };
5500
+ let outcome = "succeeded";
5501
+ let result;
5502
+ let errorMessage;
5503
+ try {
5504
+ const ctx = this.makeContext(row.id, controller.signal);
5505
+ result = await this.onJob(job, ctx);
5506
+ if (this.continueState) outcome = "continued";
5507
+ } catch (e) {
5508
+ outcome = "failed";
5509
+ errorMessage = e instanceof Error ? e.message : String(e);
5510
+ console.error(`[JobRoom] Job "${row.type}" (${row.id}) failed:`, e);
5511
+ } finally {
5512
+ this.inFlight.delete(row.id);
5513
+ }
5514
+ const currentStatus = this.readStatus(row.id);
5515
+ if (currentStatus === "canceled") {
5516
+ this.continueState = null;
5517
+ return;
5518
+ }
5519
+ if (outcome === "continued") {
5520
+ const state = this.continueState;
5521
+ this.continueState = null;
5522
+ const nextRunAt = new Date(Date.now() + Math.max(0, state.afterMs)).toISOString();
5523
+ this.sql.exec(
5524
+ `UPDATE jobs
5525
+ SET status = 'queued',
5526
+ checkpoint = ?,
5527
+ next_run_at = ?
5528
+ WHERE id = ?`,
5529
+ safeStringify(state.checkpoint),
5530
+ nextRunAt,
5531
+ row.id
5532
+ );
5533
+ const updated2 = this.getJobById(row.id);
5534
+ if (updated2) this.broadcast(serverBuild.jobProgress(updated2));
5535
+ return;
5536
+ }
5537
+ const completedAt = (/* @__PURE__ */ new Date()).toISOString();
5538
+ if (outcome === "succeeded") {
5539
+ this.sql.exec(
5540
+ `UPDATE jobs
5541
+ SET status = 'succeeded',
5542
+ result = ?,
5543
+ completed_at = ?,
5544
+ error = NULL,
5545
+ checkpoint = NULL,
5546
+ next_run_at = NULL,
5547
+ progress = NULL,
5548
+ progress_message = NULL
5549
+ WHERE id = ?`,
5550
+ result === void 0 ? null : safeStringify(result),
5551
+ completedAt,
5552
+ row.id
5553
+ );
5554
+ const updated2 = this.getJobById(row.id);
5555
+ if (updated2) this.broadcast(serverBuild.jobSucceeded(updated2));
5556
+ return;
5557
+ }
5558
+ if (attempt < row.max_attempts) {
5559
+ const nextRunAt = new Date(Date.now() + this.retryBackoffMs).toISOString();
5560
+ this.sql.exec(
5561
+ `UPDATE jobs
5562
+ SET status = 'queued',
5563
+ error = ?,
5564
+ next_run_at = ?,
5565
+ started_at = NULL,
5566
+ progress = NULL,
5567
+ progress_message = NULL
5568
+ WHERE id = ?`,
5569
+ errorMessage ?? "unknown error",
5570
+ nextRunAt,
5571
+ row.id
5572
+ );
5573
+ const updated2 = this.getJobById(row.id);
5574
+ if (updated2) this.broadcast(serverBuild.jobRetried(updated2));
5575
+ return;
5576
+ }
5577
+ this.sql.exec(
5578
+ `UPDATE jobs
5579
+ SET status = 'failed',
5580
+ error = ?,
5581
+ completed_at = ?,
5582
+ checkpoint = NULL,
5583
+ next_run_at = NULL,
5584
+ progress = NULL,
5585
+ progress_message = NULL
5586
+ WHERE id = ?`,
5587
+ errorMessage ?? "unknown error",
5588
+ completedAt,
5589
+ row.id
5590
+ );
5591
+ const updated = this.getJobById(row.id);
5592
+ if (updated) this.broadcast(serverBuild.jobFailed(updated));
5593
+ }
5594
+ makeContext(jobId, signal) {
5595
+ return {
5596
+ progress: (value, message) => {
5597
+ const clamped = Math.max(0, Math.min(1, value));
5598
+ this.sql.exec(
5599
+ `UPDATE jobs SET progress = ?, progress_message = ? WHERE id = ?`,
5600
+ clamped,
5601
+ message ?? null,
5602
+ jobId
5603
+ );
5604
+ const updated = this.getJobById(jobId);
5605
+ if (updated) this.broadcast(serverBuild.jobProgress(updated));
5606
+ },
5607
+ continue: (state, options) => {
5608
+ this.continueState = {
5609
+ checkpoint: state,
5610
+ afterMs: Math.max(0, options?.afterMs ?? 0)
5611
+ };
5612
+ },
5613
+ signal
5614
+ };
5615
+ }
5616
+ readStatus(jobId) {
5617
+ const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
5618
+ return row?.status ?? null;
5619
+ }
5620
+ // ==========================================================================
5621
+ // Scheduling / recovery / pruning
5622
+ // ==========================================================================
5623
+ scheduleNextAlarm() {
5624
+ const row = this.sql.exec(
5625
+ `SELECT next_run_at FROM jobs
5626
+ WHERE status = 'queued'
5627
+ ORDER BY (next_run_at IS NULL) ASC, next_run_at ASC
5628
+ LIMIT 1`
5629
+ ).toArray()[0];
5630
+ if (!row) return;
5631
+ const now = Date.now();
5632
+ const target = row.next_run_at ? Math.max(now, new Date(row.next_run_at).getTime()) : now;
5633
+ this.state.storage.setAlarm(Math.max(target, now + 50));
5634
+ }
5635
+ recoverStuckRunning() {
5636
+ const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
5637
+ const stuck = this.sql.exec(
5638
+ `SELECT id, attempts, max_attempts FROM jobs
5639
+ WHERE status = 'running' AND started_at < ?`,
5640
+ cutoff
5641
+ ).toArray();
5642
+ for (const r of stuck) {
5643
+ if (r.attempts >= r.max_attempts) {
5644
+ this.sql.exec(
5645
+ `UPDATE jobs
5646
+ SET status = 'failed',
5647
+ error = 'Isolate recycled or wall time exceeded',
5648
+ completed_at = ?,
5649
+ checkpoint = NULL,
5650
+ next_run_at = NULL
5651
+ WHERE id = ?`,
5652
+ (/* @__PURE__ */ new Date()).toISOString(),
5653
+ r.id
5654
+ );
5655
+ } else {
5656
+ this.sql.exec(
5657
+ `UPDATE jobs
5658
+ SET status = 'queued',
5659
+ started_at = NULL,
5660
+ next_run_at = ?,
5661
+ error = 'Previous attempt killed mid-run; retrying',
5662
+ progress = NULL,
5663
+ progress_message = NULL
5664
+ WHERE id = ?`,
5665
+ (/* @__PURE__ */ new Date()).toISOString(),
5666
+ r.id
5667
+ );
5668
+ }
5669
+ }
5670
+ }
5671
+ pruneExpired() {
5672
+ if (this.retentionMs === 0) return;
5673
+ const cutoff = new Date(Date.now() - this.retentionMs).toISOString();
5674
+ this.sql.exec(
5675
+ `DELETE FROM jobs
5676
+ WHERE status IN ('succeeded', 'failed', 'canceled')
5677
+ AND completed_at IS NOT NULL
5678
+ AND completed_at < ?`,
5679
+ cutoff
5680
+ );
5681
+ }
5682
+ // ==========================================================================
5683
+ // Read helpers
5684
+ // ==========================================================================
5685
+ getRecentJobs(limit) {
5686
+ const rows = this.sql.exec(
5687
+ `SELECT * FROM jobs
5688
+ ORDER BY (status IN ('queued','running')) DESC, enqueued_at DESC
5689
+ LIMIT ?`,
5690
+ limit
5691
+ ).toArray();
5692
+ return rows.map(rowToJobView);
5693
+ }
5694
+ getJobById(id) {
5695
+ const row = this.sql.exec(`SELECT * FROM jobs WHERE id = ?`, id).toArray()[0];
5696
+ return row ? rowToJobView(row) : null;
5697
+ }
5698
+ };
5699
+ async function enqueueJob(namespace, roomId, type, payload, options) {
5700
+ const stub = namespace.get(namespace.idFromName(roomId));
5701
+ const res = await stub.fetch(
5702
+ new Request("https://job-room.internal/enqueue", {
5703
+ method: "POST",
5704
+ headers: { "Content-Type": "application/json" },
5705
+ body: JSON.stringify({
5706
+ type,
5707
+ payload,
5708
+ maxAttempts: options?.maxAttempts,
5709
+ enqueuedBy: options?.enqueuedBy
5710
+ })
5711
+ })
5712
+ );
5713
+ if (!res.ok) {
5714
+ const text = await res.text().catch(() => "");
5715
+ throw new Error(`enqueueJob failed: ${res.status} ${text}`);
5716
+ }
5717
+ const data = await res.json();
5718
+ if (!data.job?.id) throw new Error("enqueueJob: malformed response");
5719
+ return data.job.id;
5720
+ }
5721
+ function rowToJobView(row) {
5722
+ return {
5723
+ id: row.id,
5724
+ type: row.type,
5725
+ status: row.status,
5726
+ payload: row.payload == null ? void 0 : JSON.parse(row.payload),
5727
+ result: row.result == null ? void 0 : JSON.parse(row.result),
5728
+ error: row.error ?? void 0,
5729
+ progress: row.progress ?? void 0,
5730
+ progressMessage: row.progress_message ?? void 0,
5731
+ attempts: row.attempts,
5732
+ maxAttempts: row.max_attempts,
5733
+ enqueuedAt: row.enqueued_at,
5734
+ startedAt: row.started_at,
5735
+ completedAt: row.completed_at,
5736
+ enqueuedBy: row.enqueued_by,
5737
+ resumeFrom: row.checkpoint == null ? void 0 : JSON.parse(row.checkpoint)
5738
+ };
5739
+ }
5740
+ function safeStringify(value) {
5741
+ try {
5742
+ return JSON.stringify(value);
5743
+ } catch {
5744
+ return JSON.stringify({ __unserializable__: String(value) });
5745
+ }
5746
+ }
5747
+
5170
5748
  // src/server/rooms/do-manifest.ts
5171
5749
  var DEFAULT_DO_MANIFEST = [
5172
5750
  { binding: "RECORD_ROOMS", className: "AppRecordRoom", sqlite: true },
@@ -6248,6 +6826,7 @@ export {
6248
6826
  GLOBAL_DO_TYPES,
6249
6827
  GLOBAL_DO_TYPE_NAMES,
6250
6828
  GameRoom,
6829
+ JobRoom,
6251
6830
  MESSAGES_SCHEMA,
6252
6831
  PresenceRoom,
6253
6832
  REACTIONS_SCHEMA,
@@ -6298,6 +6877,7 @@ export {
6298
6877
  decodeJwtPayload,
6299
6878
  deleteChatCascade,
6300
6879
  deleteRecord,
6880
+ enqueueJob,
6301
6881
  executeQuery,
6302
6882
  getAllUsers,
6303
6883
  getChat,