deepspace 0.17.0 → 0.19.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.
@@ -59,7 +59,8 @@
59
59
  "The feature is served at /docs on the app's existing hostname.",
60
60
  "A hostname listed in documentation.json domains serves the same release at that hostname's root after you explicitly attach it to the app.",
61
61
  "Documentation builds only when documentation.json exists; apps without this feature are unaffected.",
62
- "MDX may import app components directly. documentation.tsx is the single optional shell customization point."
62
+ "MDX may import app components directly. documentation.tsx is the single optional shell customization point.",
63
+ "Agent discovery is available at /docs/llms.txt, /docs/llms-full.txt, /docs/skill.md, /docs/.well-known/mcp, and /docs/mcp."
63
64
  ],
64
65
  "agentNotes": [
65
66
  "Treat documentation.json, documentation/**/*.md, documentation/**/*.mdx, and optional documentation.tsx as one app-owned documentation feature.",
@@ -3,7 +3,7 @@
3
3
  "name": "Documents",
4
4
  "category": "data",
5
5
  "description": "Real-time collaborative document editing with Tiptap + Yjs",
6
- "details": "Document library with folders & favorites. Collaborative editor built on Tiptap (ProseMirror) with Y.XmlFragment via @tiptap/extension-collaboration and live remote cursors via @tiptap/extension-collaboration-caret — buttery typing, no innerHTML rebuilds, no contenteditable quirks. Invite-only documents with viewer/editor roles: owner shares via an in-app dialog that resolves an email to a DeepSpace user. RBAC is enforced by the documents schema and the starter template's built-in Yjs route. Includes a one-shot migration shim that converts documents created with the legacy contenteditable editor (HTML stored in Y.Text('content')) into Tiptap on first open.",
6
+ "details": "Document library with folders & favorites. Collaborative editor built on Tiptap (ProseMirror) with Y.XmlFragment via @tiptap/extension-collaboration and live remote cursors via @tiptap/extension-collaboration-caret — buttery typing, no innerHTML rebuilds, no contenteditable quirks. Invite-only documents with viewer/editor roles: owner shares via an in-app dialog that resolves an email to a DeepSpace user. RBAC is enforced by the documents schema and the starter template's built-in Yjs route.",
7
7
  "files": [
8
8
  {
9
9
  "src": "src/DocumentsListPage.tsx",
@@ -6,11 +6,6 @@
6
6
  * applied as granular PM transactions (no `innerHTML` swap), the local
7
7
  * caret is migrated through concurrent edits by PM's position mapping,
8
8
  * and remote cursors are painted by `@tiptap/extension-collaboration-caret`.
9
- *
10
- * Legacy documents created with the old editor stored HTML in Y.Text('content').
11
- * On first open by an editor/owner we migrate that into the new XmlFragment
12
- * via `editor.commands.setContent(html)` and clear the legacy field. The
13
- * migration is a single Yjs transaction so all peers converge.
14
9
  */
15
10
 
16
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -286,20 +281,12 @@ export default function DocumentsEditorPage() {
286
281
 
287
282
  const { put } = useMutations<DocumentsDocumentFields>('documents')
288
283
 
289
- /**
290
- * `'content'` is kept as the field name only so legacy-doc migration can
291
- * read the old HTML out of Y.Text('content') below. The Tiptap editor
292
- * itself binds to a separate Y.XmlFragment('default') inside the same
293
- * Y.Doc — they don't collide.
294
- */
295
- const {
296
- doc,
297
- text: legacyText,
298
- synced,
299
- canWrite,
300
- writeAuthResolved,
301
- awareness,
302
- } = useYjsRoom(docId ?? 'noop', 'content')
284
+ // The text helper remains available to consumers of useYjsRoom; this editor
285
+ // binds Tiptap directly to Y.XmlFragment('default') on the returned document.
286
+ const { doc, synced, connected, canWrite, writeAuthResolved, awareness } = useYjsRoom(
287
+ docId ?? 'noop',
288
+ 'content',
289
+ )
303
290
 
304
291
  const presenceAccess = useDocumentsPresenceAccess({
305
292
  docId,
@@ -388,37 +375,6 @@ export default function DocumentsEditorPage() {
388
375
  placeholder: showReadOnlyDocUx ? 'View only' : 'Start typing — toolbar above for formatting…',
389
376
  })
390
377
 
391
- // Legacy migration: if doc.getXmlFragment('default') is empty but the
392
- // legacy Y.Text('content') field has HTML, parse it once into Tiptap.
393
- // Only the first writer to open does this; viewers never see partial state
394
- // because both the wipe and the parse run inside a single Yjs transaction.
395
- const migratedRef = useRef(false)
396
- useEffect(() => {
397
- if (migratedRef.current) return
398
- if (!editor || !synced || !effectiveCanWrite) return
399
- const fragment = doc.getXmlFragment('default')
400
- if (fragment.length > 0) {
401
- migratedRef.current = true
402
- return
403
- }
404
- const yText = doc.getText('content')
405
- const legacy = (legacyText ?? yText.toString()).trim()
406
- if (!legacy) {
407
- migratedRef.current = true
408
- return
409
- }
410
- migratedRef.current = true
411
- /**
412
- * Tiptap parses the HTML against its schema, then writes the resulting
413
- * nodes into the bound Y.XmlFragment. We then clear the legacy field
414
- * inside the same transaction so peers see one atomic step.
415
- */
416
- doc.transact(() => {
417
- editor.commands.setContent(legacy, { emitUpdate: true })
418
- if (yText.length > 0) yText.delete(0, yText.length)
419
- })
420
- }, [editor, synced, effectiveCanWrite, doc, legacyText])
421
-
422
378
  const { participants: presenceParticipants, typingNames } = useDocumentsEditorPresence({
423
379
  editor,
424
380
  document: selectedDoc,
@@ -522,7 +478,7 @@ export default function DocumentsEditorPage() {
522
478
  {effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'}
523
479
  </span>
524
480
  ) : null}
525
- {synced ? (
481
+ {connected && synced ? (
526
482
  <span
527
483
  className="flex items-center gap-1.5 text-xs tabular-nums"
528
484
  style={{ color: 'var(--documents-el-muted)' }}
@@ -530,7 +486,7 @@ export default function DocumentsEditorPage() {
530
486
  <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden />
531
487
  Synced
532
488
  </span>
533
- ) : (
489
+ ) : connected ? (
534
490
  <span
535
491
  className="flex items-center gap-1.5 text-xs tabular-nums"
536
492
  style={{ color: 'var(--documents-el-muted)' }}
@@ -538,6 +494,14 @@ export default function DocumentsEditorPage() {
538
494
  <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-amber-500" aria-hidden />
539
495
  Connecting…
540
496
  </span>
497
+ ) : (
498
+ <span
499
+ className="flex items-center gap-1.5 text-xs tabular-nums"
500
+ style={{ color: 'var(--documents-el-muted)' }}
501
+ >
502
+ <span className="h-1.5 w-1.5 rounded-full bg-slate-400" aria-hidden />
503
+ Offline
504
+ </span>
541
505
  )}
542
506
  {effectiveRole === 'owner' ? (
543
507
  <Badge variant="success">Owner</Badge>
@@ -216,16 +216,14 @@ export function LibrarySidebar({
216
216
  const onCommitFolderRenameRef = useRef(onCommitRenameFolder)
217
217
  onCommitFolderRenameRef.current = onCommitRenameFolder
218
218
 
219
- useEffect(() => {
220
- return () => {
221
- // Intentionally read the ref's LATEST value at unmount to clear whatever
222
- // blur timer is pending; capturing it at effect-setup time (null) would be
223
- // wrong here since this is a timer ref, not a DOM node.
224
- // eslint-disable-next-line react-hooks/exhaustive-deps -- must clear the latest pending timer at unmount, not the setup-time value
225
- if (folderRenameBlurTimerRef.current) clearTimeout(folderRenameBlurTimerRef.current)
226
- }
219
+ const clearPendingFolderRename = useCallback(() => {
220
+ if (folderRenameBlurTimerRef.current) clearTimeout(folderRenameBlurTimerRef.current)
227
221
  }, [])
228
222
 
223
+ useEffect(() => {
224
+ return clearPendingFolderRename
225
+ }, [clearPendingFolderRename])
226
+
229
227
  const submitNewFolder = useCallback(async () => {
230
228
  const name = newFolderName.trim()
231
229
  if (!name) {
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { useEffect, useMemo, useRef } from 'react'
17
- import { useEditor, type Editor } from '@tiptap/react'
17
+ import { useEditor, type AnyExtension, type Editor } from '@tiptap/react'
18
18
  import StarterKit from '@tiptap/starter-kit'
19
19
  import Collaboration from '@tiptap/extension-collaboration'
20
20
  import { CollaborationCaret } from '@tiptap/extension-collaboration-caret'
@@ -120,14 +120,14 @@ export function useDocEditor({
120
120
  canWrite,
121
121
  placeholder = 'Start typing — toolbar above for formatting…',
122
122
  }: UseDocEditorOptions): Editor | null {
123
- /** Structured CRDT field that ProseMirror binds to — distinct from the legacy Y.Text('content'). */
123
+ /** Structured CRDT field that ProseMirror binds to. */
124
124
  const fragment = useMemo(() => doc.getXmlFragment('default'), [doc])
125
125
 
126
126
  /** CollaborationCaret expects a provider-shaped `{ awareness }` object. */
127
127
  const provider = useMemo(() => (awareness ? { awareness } : null), [awareness])
128
128
 
129
129
  const extensions = useMemo(() => {
130
- const exts: unknown[] = [
130
+ const exts: AnyExtension[] = [
131
131
  StarterKit.configure({
132
132
  // Yjs has its own undo/redo history via the Collaboration extension; keeping
133
133
  // both enabled corrupts the shared document on undo.
@@ -175,12 +175,7 @@ export function useDocEditor({
175
175
  // in dev (the editor view is destroyed and re-created, and any subscriber
176
176
  // that runs in between throws on `editor.view`). Defer to a useEffect.
177
177
  immediatelyRender: false,
178
- // Bare directive on purpose: naming @typescript-eslint/no-explicit-any
179
- // here is a hard "unknown rule" error under the scaffolded app's
180
- // minimal eslint config (which never registers that plugin), while the
181
- // SDK monorepo's config does enable the rule and needs the suppression.
182
- // eslint-disable-next-line
183
- extensions: extensions as any,
178
+ extensions,
184
179
  editorProps: {
185
180
  transformPastedHTML: stripCollaborationArtifactsFromHTML,
186
181
  attributes: {
@@ -96,13 +96,11 @@ function peerToDocumentsParticipant(peer: PresencePeerClient): DocumentsPresence
96
96
  const participant: DocumentsPresenceParticipant = {
97
97
  clientId: 0,
98
98
  userId: peer.userId,
99
- name: peer.userName?.trim() || peer.userEmail?.trim() || 'Guest',
99
+ name: peer.userName?.trim() || 'Guest',
100
100
  mode,
101
101
  typing: state.typing === true,
102
102
  isSelf: false,
103
103
  }
104
- if (peer.userEmail) participant.email = peer.userEmail
105
- if (peer.userImageUrl) participant.imageUrl = peer.userImageUrl
106
104
  if (lastTypedAt != null) participant.lastTypedAt = lastTypedAt
107
105
  return participant
108
106
  }
@@ -59,7 +59,7 @@
59
59
  "framer-motion": "^12.38.0"
60
60
  },
61
61
  "instructions": [
62
- "Configure APP_NAME, HERO_HEADLINE, HERO_SUBHEADLINE, FAQ_ITEMS etc. in LandingPage.tsx"
62
+ "Configure APP_NAME, HERO_HEADLINE, HERO_SUBHEADLINE, FAQ_ITEMS, etc. in src/pages/(app)/landing.tsx"
63
63
  ],
64
64
  "patterns": []
65
65
  }
@@ -14,7 +14,14 @@ import type { GroupedReaction } from 'deepspace'
14
14
  import { UserProfilePopover } from './UserProfilePopover'
15
15
  import { useLongPress } from '../hooks/useLongPress'
16
16
 
17
- const QUICK_EMOJIS = ['\u{1F44D}', '\u2764\uFE0F', '\u{1F602}', '\u{1F389}', '\u{1F525}', '\u{1F440}']
17
+ const QUICK_EMOJIS = [
18
+ '\u{1F44D}',
19
+ '\u2764\uFE0F',
20
+ '\u{1F602}',
21
+ '\u{1F389}',
22
+ '\u{1F525}',
23
+ '\u{1F440}',
24
+ ]
18
25
 
19
26
  export interface MessageRect {
20
27
  top: number
@@ -61,6 +68,8 @@ export function MessageItem({
61
68
  const [editContent, setEditContent] = useState(message.data.content)
62
69
  const editContainerRef = useRef<HTMLDivElement>(null)
63
70
  const contentRef = useRef<HTMLDivElement>(null)
71
+ const latestMessageContentRef = useRef(message.data.content)
72
+ latestMessageContentRef.current = message.data.content
64
73
 
65
74
  const authorId = message.data.authorId || message.createdBy
66
75
  const author = getUser(authorId)
@@ -78,27 +87,25 @@ export function MessageItem({
78
87
  height: rect.height,
79
88
  })
80
89
  },
81
- { delay: 500, threshold: 10 }
90
+ { delay: 500, threshold: 10 },
82
91
  )
83
92
 
84
93
  useEffect(() => {
85
- if (forceEdit && !isEditing) {
86
- setIsEditing(true)
87
- setEditContent(message.data.content)
88
- }
89
- // Deps intentionally limited to [forceEdit]: this reacts only to the
90
- // forceEdit trigger. Including isEditing/message.data.content would re-fire
91
- // on every keystroke and clobber the in-progress edit buffer.
92
- // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot on forceEdit; adding isEditing/message.data.content would re-fire on keystrokes and clobber the edit buffer
93
- }, [forceEdit])
94
+ if (!forceEdit || isEditing) return
95
+ setEditContent(latestMessageContentRef.current)
96
+ setIsEditing(true)
97
+ }, [forceEdit, isEditing])
94
98
 
95
99
  const timestamp = new Date(message.createdAt)
96
100
  const timeStr = timestamp.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
97
- const dateTimeStr = timestamp.toLocaleDateString(undefined, {
98
- month: '2-digit',
99
- day: '2-digit',
100
- year: '2-digit',
101
- }) + ', ' + timeStr
101
+ const dateTimeStr =
102
+ timestamp.toLocaleDateString(undefined, {
103
+ month: '2-digit',
104
+ day: '2-digit',
105
+ year: '2-digit',
106
+ }) +
107
+ ', ' +
108
+ timeStr
102
109
 
103
110
  const exitEdit = useCallback(() => {
104
111
  setIsEditing(false)
@@ -173,10 +180,7 @@ export function MessageItem({
173
180
  }`}
174
181
  >
175
182
  <div className="shrink-0 w-10" />
176
- <p
177
- data-testid="deleted-tombstone"
178
- className="text-sm italic text-muted-foreground/70"
179
- >
183
+ <p data-testid="deleted-tombstone" className="text-sm italic text-muted-foreground/70">
180
184
  This message was deleted.
181
185
  </p>
182
186
  </div>
@@ -205,10 +209,7 @@ export function MessageItem({
205
209
  </div>
206
210
 
207
211
  {/* Content column */}
208
- <div
209
- ref={contentRef}
210
- className={`flex-1 min-w-0 ${isHighlighted ? 'invisible' : ''}`}
211
- >
212
+ <div ref={contentRef} className={`flex-1 min-w-0 ${isHighlighted ? 'invisible' : ''}`}>
212
213
  {isFirstInGroup && (
213
214
  <div className="flex items-baseline gap-2 mb-0.5">
214
215
  <UserProfilePopover userId={authorId} onStartDM={onStartDM}>
@@ -239,7 +240,13 @@ export function MessageItem({
239
240
  className="p-1.5 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
240
241
  title="Save"
241
242
  >
242
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
243
+ <svg
244
+ className="w-4 h-4"
245
+ fill="none"
246
+ viewBox="0 0 24 24"
247
+ stroke="currentColor"
248
+ strokeWidth={2}
249
+ >
243
250
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
244
251
  </svg>
245
252
  </button>
@@ -249,7 +256,13 @@ export function MessageItem({
249
256
  className="p-1.5 rounded-md bg-muted text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors"
250
257
  title="Cancel"
251
258
  >
252
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
259
+ <svg
260
+ className="w-4 h-4"
261
+ fill="none"
262
+ viewBox="0 0 24 24"
263
+ stroke="currentColor"
264
+ strokeWidth={2}
265
+ >
253
266
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
254
267
  </svg>
255
268
  </button>
@@ -257,7 +270,10 @@ export function MessageItem({
257
270
  </div>
258
271
  </div>
259
272
  ) : (
260
- <p className="text-sm text-foreground/90 whitespace-pre-wrap break-words leading-relaxed" data-testid="message-content">
273
+ <p
274
+ className="text-sm text-foreground/90 whitespace-pre-wrap break-words leading-relaxed"
275
+ data-testid="message-content"
276
+ >
261
277
  {message.data.content}
262
278
  {message.data.edited ? (
263
279
  <span className="text-[10px] ml-1.5 text-muted-foreground italic">(edited)</span>
@@ -300,7 +316,10 @@ export function MessageItem({
300
316
 
301
317
  {/* Desktop hover toolbar */}
302
318
  {!isEditing && (
303
- <div data-testid={`hover-toolbar-${message.recordId}`} className="absolute -top-3 right-5 flex items-center gap-0.5 bg-card border border-border/60 rounded-lg shadow-sm p-0.5 transition-all duration-100 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto">
319
+ <div
320
+ data-testid={`hover-toolbar-${message.recordId}`}
321
+ className="absolute -top-3 right-5 flex items-center gap-0.5 bg-card border border-border/60 rounded-lg shadow-sm p-0.5 transition-all duration-100 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
322
+ >
304
323
  {QUICK_EMOJIS.map((emoji) => (
305
324
  <button
306
325
  key={emoji}
@@ -318,8 +337,18 @@ export function MessageItem({
318
337
  className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
319
338
  title="Reply in thread"
320
339
  >
321
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
322
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
340
+ <svg
341
+ className="w-3.5 h-3.5"
342
+ fill="none"
343
+ viewBox="0 0 24 24"
344
+ stroke="currentColor"
345
+ strokeWidth={1.5}
346
+ >
347
+ <path
348
+ strokeLinecap="round"
349
+ strokeLinejoin="round"
350
+ d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
351
+ />
323
352
  </svg>
324
353
  </button>
325
354
  {isOwn && (
@@ -333,8 +362,18 @@ export function MessageItem({
333
362
  className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
334
363
  title="Edit message"
335
364
  >
336
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
337
- <path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
365
+ <svg
366
+ className="w-3.5 h-3.5"
367
+ fill="none"
368
+ viewBox="0 0 24 24"
369
+ stroke="currentColor"
370
+ strokeWidth={1.5}
371
+ >
372
+ <path
373
+ strokeLinecap="round"
374
+ strokeLinejoin="round"
375
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
376
+ />
338
377
  </svg>
339
378
  </button>
340
379
  <button
@@ -343,8 +382,18 @@ export function MessageItem({
343
382
  className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
344
383
  title="Delete message"
345
384
  >
346
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
347
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
385
+ <svg
386
+ className="w-3.5 h-3.5"
387
+ fill="none"
388
+ viewBox="0 0 24 24"
389
+ stroke="currentColor"
390
+ strokeWidth={1.5}
391
+ >
392
+ <path
393
+ strokeLinecap="round"
394
+ strokeLinejoin="round"
395
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
396
+ />
348
397
  </svg>
349
398
  </button>
350
399
  </>
@@ -36,9 +36,7 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
36
36
  const defaultChannel = channels
37
37
  .filter(
38
38
  (c: RecordData<Channel>) =>
39
- c.data.name === channelName &&
40
- c.data.type === 'public' &&
41
- !c.data.archived,
39
+ c.data.name === channelName && c.data.type === 'public' && !c.data.archived,
42
40
  )
43
41
  .sort(
44
42
  (a: RecordData<Channel>, b: RecordData<Channel>) =>
@@ -59,11 +57,7 @@ export function useChatChannel(channelName: string = DEFAULT_CHANNEL_NAME) {
59
57
  description: 'Default chat channel',
60
58
  })
61
59
  }
62
- // Deps intentionally limited to [user, status]: this is a one-shot init
63
- // guarded by hasInitialized. Including defaultChannel/create/channelName
64
- // would re-run and defeat the create-once semantics.
65
- // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot init guarded by hasInitialized; adding defaultChannel/create/channelName would defeat create-once
66
- }, [user, status])
60
+ }, [user, status, defaultChannel, create, channelName])
67
61
 
68
62
  // Wait for the members query to be ready before auto-joining.
69
63
  useEffect(() => {
@@ -460,7 +460,10 @@ export default function TasksPage({ className }: TasksPageProps) {
460
460
  const handleUnclaim = async (challenge: { recordId: string; data: Challenge }) => {
461
461
  await put(challenge.recordId, {
462
462
  ...challenge.data,
463
- claimedById: undefined,
463
+ // Empty strings are normalized to SQL NULL by the record writer. The
464
+ // read model omits null database fields, so this also keeps Challenge's
465
+ // optional-string shape honest.
466
+ claimedById: '',
464
467
  })
465
468
  }
466
469
 
@@ -469,8 +472,8 @@ export default function TasksPage({ className }: TasksPageProps) {
469
472
  await put(submitChallenge.recordId, {
470
473
  ...submitChallenge.data,
471
474
  submitted: true,
472
- submissionUrl: url || undefined,
473
- submissionNotes: notes || undefined,
475
+ submissionUrl: url,
476
+ submissionNotes: notes,
474
477
  })
475
478
  setSubmitChallenge(null)
476
479
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "engines": {
5
- "node": ">=22.15.0"
5
+ "node": ">=22.15.0 <23 || >=24 <25 || >=26 <27"
6
6
  },
7
7
  "description": "DeepSpace SDK — build real-time collaborative apps on Cloudflare Workers",
8
8
  "type": "module",