deepspace 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,11 @@ import type { RecordData } from 'deepspace'
3
3
  import { useMutations, useQuery, useUser } from 'deepspace'
4
4
  import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
5
5
  import { Modal, useToast } from '@/components/ui'
6
- import { parseDocumentsIdList, type DocumentsDocumentFields, type InviteAclDiff } from './documents-library-types'
6
+ import {
7
+ parseDocumentsIdList,
8
+ type DocumentsDocumentFields,
9
+ type InviteAclDiff,
10
+ } from './documents-library-types'
7
11
 
8
12
  interface UserFields {
9
13
  email?: string
@@ -42,12 +46,14 @@ function initialsFor(name: string): string {
42
46
 
43
47
  export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
44
48
  const { user } = useUser()
45
- const { records: users } = useQuery<UserFields>('users')
46
- const { put } = useMutations<DocumentsDocumentFields>('documents')
49
+ const { records: users, status: usersStatus } = useQuery<UserFields>('users')
50
+ const { putConfirmed, ready: documentsMutationsReady } =
51
+ useMutations<DocumentsDocumentFields>('documents')
47
52
  const toast = useToast()
48
53
  const [email, setEmail] = useState('')
49
54
  const [role, setRole] = useState<InviteRole>('editor')
50
55
  const [saving, setSaving] = useState(false)
56
+ const ready = usersStatus === 'ready' && documentsMutationsReady
51
57
 
52
58
  const collaborators = useMemo(
53
59
  () => parseDocumentsIdList(doc.data.collaborators),
@@ -70,7 +76,11 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
70
76
 
71
77
  if (!isOwner) return null
72
78
 
73
- const saveAccess = async (nextCollaborators: string[], nextEditors: string[]) => {
79
+ const saveAccess = async (
80
+ nextCollaborators: string[],
81
+ nextEditors: string[],
82
+ ): Promise<boolean> => {
83
+ if (!ready) return false
74
84
  const prevCollaborators = parseDocumentsIdList(doc.data.collaborators)
75
85
  const prevEditors = parseDocumentsIdList(doc.data.editors)
76
86
  const nextCollabList = uniqueIds(nextCollaborators)
@@ -80,7 +90,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
80
90
 
81
91
  setSaving(true)
82
92
  try {
83
- await put(doc.recordId, {
93
+ await putConfirmed(doc.recordId, {
84
94
  ...doc.data,
85
95
  collaborators: JSON.stringify(nextCollabList),
86
96
  editors: JSON.stringify(nextEditorList),
@@ -98,6 +108,13 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
98
108
  onAclChange({ removedUserIds, demotedUserIds, promotedUserIds })
99
109
  }
100
110
  }
111
+ return true
112
+ } catch {
113
+ toast.error(
114
+ 'Access not saved',
115
+ 'DeepSpace did not confirm the change. Reconnect and try again.',
116
+ )
117
+ return false
101
118
  } finally {
102
119
  setSaving(false)
103
120
  }
@@ -109,7 +126,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
109
126
 
110
127
  const target = users.find((u) => u.data.email?.trim().toLowerCase() === normalized)
111
128
  if (!target) {
112
- toast.error('User not found', 'No DeepSpace user with that email has used this app yet.')
129
+ toast.error(
130
+ 'User not found',
131
+ 'Ask them to sign in to this app once, then invite the same email again.',
132
+ )
113
133
  return
114
134
  }
115
135
  if (target.recordId === doc.data.ownerId || target.recordId === user?.id) {
@@ -123,7 +143,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
123
143
 
124
144
  const nextCollaborators = [...collaborators, target.recordId]
125
145
  const nextEditors = role === 'editor' ? [...editors, target.recordId] : editors
126
- await saveAccess(nextCollaborators, nextEditors)
146
+ if (!(await saveAccess(nextCollaborators, nextEditors))) return
127
147
  setEmail('')
128
148
  toast.success('Invite added', `${target.data.email ?? normalized} now has ${role} access.`)
129
149
  }
@@ -151,7 +171,12 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
151
171
  'Owner'
152
172
 
153
173
  return (
154
- <Modal open={open} onClose={() => onOpenChange(false)} size="lg" className="documents-feature-scope">
174
+ <Modal
175
+ open={open}
176
+ onClose={() => onOpenChange(false)}
177
+ size="lg"
178
+ className="documents-feature-scope"
179
+ >
155
180
  <Modal.Header>
156
181
  <Modal.Title>Share document</Modal.Title>
157
182
  <Modal.Description>
@@ -174,6 +199,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
174
199
  style={{ color: 'var(--documents-el-muted)' }}
175
200
  />
176
201
  <input
202
+ disabled={!ready || saving}
177
203
  value={email}
178
204
  onChange={(e) => setEmail(e.target.value)}
179
205
  onKeyDown={(e) => {
@@ -181,7 +207,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
181
207
  }}
182
208
  placeholder="person@gmail.com"
183
209
  className="h-10 w-full rounded-lg border bg-transparent pl-9 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
184
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
210
+ style={{
211
+ borderColor: 'var(--documents-el-line)',
212
+ color: 'var(--documents-el-text)',
213
+ }}
185
214
  />
186
215
  </div>
187
216
  <select
@@ -196,7 +225,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
196
225
  <button
197
226
  type="button"
198
227
  onClick={() => void addInvite()}
199
- disabled={saving || !email.trim()}
228
+ disabled={!ready || saving || !email.trim()}
200
229
  className="h-10 rounded-lg px-4 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
201
230
  style={{ backgroundColor: 'var(--documents-el-accent)' }}
202
231
  >
@@ -253,7 +282,10 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
253
282
  )}
254
283
  <div className="min-w-0 flex-1">
255
284
  <div className="truncate text-sm font-medium">{name}</div>
256
- <div className="truncate text-xs" style={{ color: 'var(--documents-el-muted)' }}>
285
+ <div
286
+ className="truncate text-xs"
287
+ style={{ color: 'var(--documents-el-muted)' }}
288
+ >
257
289
  {u.data.email ?? 'No email'}
258
290
  </div>
259
291
  </div>
@@ -262,9 +294,12 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
262
294
  onChange={(e) =>
263
295
  void setCollaboratorRole(u.recordId, e.target.value as InviteRole)
264
296
  }
265
- disabled={saving}
297
+ disabled={!ready || saving}
266
298
  className="h-8 rounded-lg border bg-transparent px-2 text-xs font-medium outline-none"
267
- style={{ borderColor: 'var(--documents-el-line)', color: 'var(--documents-el-text)' }}
299
+ style={{
300
+ borderColor: 'var(--documents-el-line)',
301
+ color: 'var(--documents-el-text)',
302
+ }}
268
303
  >
269
304
  <option value="editor">Editor</option>
270
305
  <option value="viewer">Viewer</option>
@@ -272,7 +307,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
272
307
  <button
273
308
  type="button"
274
309
  onClick={() => void removeCollaborator(u.recordId)}
275
- disabled={saving}
310
+ disabled={!ready || saving}
276
311
  className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-500/10 disabled:opacity-50"
277
312
  title="Remove access"
278
313
  >
@@ -182,6 +182,7 @@ export interface LibrarySidebarProps {
182
182
  selection: LibraryNavSelection
183
183
  onSelect: (s: LibraryNavSelection) => void
184
184
  folders: RecordData<DocFolderFields>[]
185
+ canManageFolders: boolean
185
186
  collapsed: boolean
186
187
  onToggleCollapsed: () => void
187
188
  onCreateFolder: (name: string) => void | Promise<void>
@@ -198,6 +199,7 @@ export function LibrarySidebar({
198
199
  selection,
199
200
  onSelect,
200
201
  folders,
202
+ canManageFolders,
201
203
  collapsed,
202
204
  onToggleCollapsed,
203
205
  onCreateFolder,
@@ -337,7 +339,7 @@ export function LibrarySidebar({
337
339
  </span>
338
340
  <div className="space-y-0.5">
339
341
  {sortedFolders.map((f) =>
340
- renamingFolderId === f.recordId ? (
342
+ canManageFolders && renamingFolderId === f.recordId ? (
341
343
  <div
342
344
  key={f.recordId}
343
345
  className="group flex w-full min-w-0 items-center gap-0.5 rounded-md bg-[color:var(--documents-el-accent)]/10 py-0.5 pl-0.5 dark:bg-[color:var(--documents-el-accent)]/15"
@@ -369,96 +371,102 @@ export function LibrarySidebar({
369
371
  onClick={() => onSelect({ kind: 'folder', folderId: f.recordId })}
370
372
  collapsed={collapsed}
371
373
  trailing={
372
- <span className="relative z-[1] flex shrink-0 items-center gap-0.5">
373
- <button
374
- type="button"
375
- data-testid={`rename-folder-${f.recordId}`}
376
- onMouseDown={(e) => {
377
- e.preventDefault()
378
- e.stopPropagation()
379
- }}
380
- onClick={(e) => {
381
- e.stopPropagation()
382
- onStartRenameFolder(f)
383
- }}
384
- className="rounded p-1 text-[color:var(--documents-el-muted)] opacity-0 transition-all hover:bg-black/[0.04] hover:text-[color:var(--documents-el-accent)] group-hover:opacity-100 dark:hover:bg-white/[0.06]"
385
- title="Rename folder"
386
- aria-label={`Rename folder ${f.data.name}`}
387
- >
388
- <Pencil className="h-3.5 w-3.5" />
389
- </button>
390
- <button
391
- type="button"
392
- data-testid={`delete-folder-${f.recordId}`}
393
- onClick={(e) => {
394
- e.stopPropagation()
395
- if (
396
- confirm(
397
- `Delete folder “${f.data.name}”? Documents inside will move to uncategorized.`,
398
- )
399
- ) {
400
- void onDeleteFolder(f.recordId)
401
- }
402
- }}
403
- className="rounded p-1 text-[color:var(--documents-el-muted)] opacity-0 transition-all hover:bg-red-500/10 hover:text-red-600 group-hover:opacity-100"
404
- title="Delete folder"
405
- aria-label={`Delete folder ${f.data.name}`}
406
- >
407
- <Trash2 className="h-3.5 w-3.5" />
408
- </button>
409
- </span>
374
+ canManageFolders ? (
375
+ <span className="relative z-[1] flex shrink-0 items-center gap-0.5">
376
+ <button
377
+ type="button"
378
+ data-testid={`rename-folder-${f.recordId}`}
379
+ onMouseDown={(e) => {
380
+ e.preventDefault()
381
+ e.stopPropagation()
382
+ }}
383
+ onClick={(e) => {
384
+ e.stopPropagation()
385
+ onStartRenameFolder(f)
386
+ }}
387
+ className="rounded p-1 text-[color:var(--documents-el-muted)] opacity-0 transition-all hover:bg-black/[0.04] hover:text-[color:var(--documents-el-accent)] group-hover:opacity-100 dark:hover:bg-white/[0.06]"
388
+ title="Rename folder"
389
+ aria-label={`Rename folder ${f.data.name}`}
390
+ >
391
+ <Pencil className="h-3.5 w-3.5" />
392
+ </button>
393
+ <button
394
+ type="button"
395
+ data-testid={`delete-folder-${f.recordId}`}
396
+ onClick={(e) => {
397
+ e.stopPropagation()
398
+ if (
399
+ confirm(
400
+ `Delete folder “${f.data.name}”? Documents inside will move to uncategorized.`,
401
+ )
402
+ ) {
403
+ void onDeleteFolder(f.recordId)
404
+ }
405
+ }}
406
+ className="rounded p-1 text-[color:var(--documents-el-muted)] opacity-0 transition-all hover:bg-red-500/10 hover:text-red-600 group-hover:opacity-100"
407
+ title="Delete folder"
408
+ aria-label={`Delete folder ${f.data.name}`}
409
+ >
410
+ <Trash2 className="h-3.5 w-3.5" />
411
+ </button>
412
+ </span>
413
+ ) : undefined
410
414
  }
411
415
  />
412
416
  ),
413
417
  )}
414
418
 
415
- {addingFolder ? (
416
- <div className="py-1">
417
- <input
418
- autoFocus
419
- value={newFolderName}
420
- onChange={(e) => setNewFolderName(e.target.value)}
421
- onBlur={() => void submitNewFolder()}
422
- onKeyDown={(e) => {
423
- if (e.key === 'Enter') void submitNewFolder()
424
- if (e.key === 'Escape') {
425
- setAddingFolder(false)
426
- setNewFolderName('')
419
+ {canManageFolders &&
420
+ (addingFolder ? (
421
+ <div className="py-1">
422
+ <input
423
+ autoFocus
424
+ value={newFolderName}
425
+ onChange={(e) => setNewFolderName(e.target.value)}
426
+ onBlur={() => void submitNewFolder()}
427
+ onKeyDown={(e) => {
428
+ if (e.key === 'Enter') void submitNewFolder()
429
+ if (e.key === 'Escape') {
430
+ setAddingFolder(false)
431
+ setNewFolderName('')
432
+ }
433
+ }}
434
+ placeholder="Folder name"
435
+ data-testid="new-folder-input"
436
+ className="w-full rounded-md border px-2 py-1.5 text-[13px] outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
437
+ style={{
438
+ borderColor: 'var(--documents-el-line)',
439
+ backgroundColor: 'var(--documents-el-bg)',
440
+ color: 'var(--documents-el-text)',
441
+ }}
442
+ />
443
+ </div>
444
+ ) : (
445
+ <button
446
+ type="button"
447
+ data-testid="new-folder-btn"
448
+ onClick={() => {
449
+ if (!collapsed) {
450
+ setAddingFolder(true)
451
+ return
427
452
  }
453
+ const name = window.prompt('Folder name')?.trim()
454
+ if (name) void onCreateFolder(name)
428
455
  }}
429
- placeholder="Folder name"
430
- data-testid="new-folder-input"
431
- className="w-full rounded-md border px-2 py-1.5 text-[13px] outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
432
- style={{
433
- borderColor: 'var(--documents-el-line)',
434
- backgroundColor: 'var(--documents-el-bg)',
435
- color: 'var(--documents-el-text)',
436
- }}
437
- />
438
- </div>
439
- ) : (
440
- <button
441
- type="button"
442
- data-testid="new-folder-btn"
443
- onClick={() => {
444
- if (!collapsed) {
445
- setAddingFolder(true)
446
- return
447
- }
448
- const name = window.prompt('Folder name')?.trim()
449
- if (name) void onCreateFolder(name)
450
- }}
451
- title={collapsed ? 'New folder' : undefined}
452
- className="flex h-9 w-full min-w-0 items-center gap-0 rounded-md pl-0.5 pr-0 text-left text-[13px] text-[color:var(--documents-el-muted)] transition-colors hover:bg-black/[0.04] hover:text-[color:var(--documents-el-accent)] dark:hover:bg-white/[0.06]"
453
- >
454
- <span className="flex h-9 w-10 shrink-0 items-center justify-center" aria-hidden>
455
- <FolderPlus className="h-3.5 w-3.5 shrink-0" />
456
- </span>
457
- <span className={collapsed ? 'sr-only' : 'min-w-0 flex-1 truncate pr-2'}>
458
- New folder
459
- </span>
460
- </button>
461
- )}
456
+ title={collapsed ? 'New folder' : undefined}
457
+ className="flex h-9 w-full min-w-0 items-center gap-0 rounded-md pl-0.5 pr-0 text-left text-[13px] text-[color:var(--documents-el-muted)] transition-colors hover:bg-black/[0.04] hover:text-[color:var(--documents-el-accent)] dark:hover:bg-white/[0.06]"
458
+ >
459
+ <span
460
+ className="flex h-9 w-10 shrink-0 items-center justify-center"
461
+ aria-hidden
462
+ >
463
+ <FolderPlus className="h-3.5 w-3.5 shrink-0" />
464
+ </span>
465
+ <span className={collapsed ? 'sr-only' : 'min-w-0 flex-1 truncate pr-2'}>
466
+ New folder
467
+ </span>
468
+ </button>
469
+ ))}
462
470
  </div>
463
471
  </div>
464
472
  </nav>
@@ -5,6 +5,15 @@ import {
5
5
  isDocumentsOwner,
6
6
  resolveDocumentsAccessRole,
7
7
  } from '../use-documents-presence-access'
8
+ import { recordsReadyForMutation } from '../documents-library-types'
9
+
10
+ describe('document mutation readiness', () => {
11
+ it('requires both a ready query and a ready mutation transport', () => {
12
+ expect(recordsReadyForMutation('loading', true)).toBe(false)
13
+ expect(recordsReadyForMutation('ready', false)).toBe(false)
14
+ expect(recordsReadyForMutation('ready', true)).toBe(true)
15
+ })
16
+ })
8
17
 
9
18
  describe('document access policy', () => {
10
19
  const document = {
@@ -42,8 +51,12 @@ describe('document access policy', () => {
42
51
  expect(deriveDocumentsAccessChange('viewer', 'editor', null)).toBe('upgrade')
43
52
  expect(deriveDocumentsAccessChange('viewer', 'none', null)).toBe('revoked')
44
53
  expect(deriveDocumentsAccessChange('owner', 'owner', null)).toBeNull()
45
- expect(deriveDocumentsAccessChange(null, 'viewer', { kind: 'downgrade', at: 20 })).toBe('downgrade')
46
- expect(deriveDocumentsAccessChange('editor', 'viewer', { kind: 'revoked', at: 20 })).toBe('revoked')
54
+ expect(deriveDocumentsAccessChange(null, 'viewer', { kind: 'downgrade', at: 20 })).toBe(
55
+ 'downgrade',
56
+ )
57
+ expect(deriveDocumentsAccessChange('editor', 'viewer', { kind: 'revoked', at: 20 })).toBe(
58
+ 'revoked',
59
+ )
47
60
  })
48
61
 
49
62
  it('accepts only a fresh ACL signal addressed to the current user', () => {
@@ -29,6 +29,12 @@ export type LibraryNavSelection =
29
29
  | { kind: 'uncategorized' }
30
30
  | { kind: 'folder'; folderId: string }
31
31
 
32
+ /** Record controls are usable only after both the initial query and mutation
33
+ * transport are ready. Keep that boundary identical across feature pages. */
34
+ export function recordsReadyForMutation(queryStatus: string, mutationsReady: boolean): boolean {
35
+ return queryStatus === 'ready' && mutationsReady
36
+ }
37
+
32
38
  export function parseDocumentsIdList(raw: string | undefined): string[] {
33
39
  if (!raw) return []
34
40
  try {
@@ -120,7 +120,7 @@ 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. */
@@ -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
  }
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "engines": {
5
5
  "node": ">=22.15.0 <23 || >=24 <25 || >=26 <27"
6
6
  },
@@ -56,13 +56,13 @@
56
56
  "prepublishOnly": "pnpm build"
57
57
  },
58
58
  "dependencies": {
59
- "@ai-sdk/anthropic": "^2.0.0",
60
- "@ai-sdk/openai": "^2.0.0",
61
- "@ai-sdk/openai-compatible": "^1.0.0",
59
+ "@ai-sdk/anthropic": "2.0.91",
60
+ "@ai-sdk/openai": "2.0.116",
61
+ "@ai-sdk/openai-compatible": "1.0.46",
62
62
  "@clack/prompts": "^1.2.0",
63
63
  "@mdx-js/mdx": "^3.1.1",
64
64
  "acorn": "^8.16.0",
65
- "ai": "^5.0.0",
65
+ "ai": "5.0.222",
66
66
  "better-auth": "1.6.23",
67
67
  "citty": "^0.2.0",
68
68
  "cross-spawn": "^7.0.6",