@open-mercato/documents 0.10.1-develop.7925.1.ec40f75262 → 0.10.1-develop.7929.1.0822b5d5fd
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/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js +12 -0
- package/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js.map +2 -2
- package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js +15 -1
- package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js.map +2 -2
- package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js +20 -8
- package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js.map +2 -2
- package/dist/modules/documents/backend/documents/[id]/editorTypes.js.map +2 -2
- package/dist/modules/documents/backend/documents/[id]/saveCollaborativeContent.js +46 -0
- package/dist/modules/documents/backend/documents/[id]/saveCollaborativeContent.js.map +7 -0
- package/dist/modules/documents/data/validators.js +11 -0
- package/dist/modules/documents/data/validators.js.map +2 -2
- package/dist/modules/documents/lib/collabPersistence.js +13 -0
- package/dist/modules/documents/lib/collabPersistence.js.map +7 -0
- package/dist/server/documents-collab-server.js +74 -1
- package/dist/server/documents-collab-server.js.map +2 -2
- package/package.json +6 -6
- package/server/documents-collab-server.ts +79 -0
- package/src/modules/documents/backend/documents/[id]/DocumentEditorSurface.tsx +16 -0
- package/src/modules/documents/backend/documents/[id]/DocumentPageClient.tsx +14 -0
- package/src/modules/documents/backend/documents/[id]/VersionHistoryPanel.tsx +23 -8
- package/src/modules/documents/backend/documents/[id]/editorTypes.ts +1 -0
- package/src/modules/documents/backend/documents/[id]/saveCollaborativeContent.ts +43 -0
- package/src/modules/documents/data/validators.ts +12 -0
- package/src/modules/documents/lib/collabPersistence.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/documents",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
"@hocuspocus/server": "^4.4.0",
|
|
76
76
|
"@hocuspocus/transformer": "^4.3.0",
|
|
77
77
|
"@mikro-orm/core": "^7.1.14",
|
|
78
|
-
"@open-mercato/events": "0.10.1-develop.
|
|
79
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
78
|
+
"@open-mercato/events": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
79
|
+
"@open-mercato/ui": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
80
80
|
"@tiptap/core": "3.32.0",
|
|
81
81
|
"@tiptap/extension-bubble-menu": "3.32.0",
|
|
82
82
|
"@tiptap/extension-collaboration": "3.32.0",
|
|
@@ -112,13 +112,13 @@
|
|
|
112
112
|
},
|
|
113
113
|
"peerDependencies": {
|
|
114
114
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
115
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
115
|
+
"@open-mercato/shared": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
116
116
|
"react": "^19.0.0",
|
|
117
117
|
"react-dom": "^19.0.0"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
121
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
120
|
+
"@open-mercato/core": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
121
|
+
"@open-mercato/shared": "0.10.1-develop.7929.1.0822b5d5fd",
|
|
122
122
|
"@types/jest": "^30.0.0",
|
|
123
123
|
"@types/pg": "^8.23.1",
|
|
124
124
|
"@types/react": "^19.2.18",
|
|
@@ -26,9 +26,12 @@ import type {
|
|
|
26
26
|
onChangePayload,
|
|
27
27
|
onRequestPayload,
|
|
28
28
|
onStoreDocumentPayload,
|
|
29
|
+
onStatelessPayload,
|
|
29
30
|
Server as HocuspocusServer,
|
|
30
31
|
} from '@hocuspocus/server'
|
|
31
32
|
import * as Y from 'yjs'
|
|
33
|
+
import { collabContentSaveRequestSchema } from '@open-mercato/documents/modules/documents/data/validators'
|
|
34
|
+
import { containsDocumentSnapshot } from '@open-mercato/documents/modules/documents/lib/collabPersistence'
|
|
32
35
|
import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch'
|
|
33
36
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
34
37
|
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
@@ -2505,6 +2508,32 @@ export function createCollabHooks(deps: CollabHooksDeps) {
|
|
|
2505
2508
|
assertConnectionAuthorization,
|
|
2506
2509
|
establishConnectionAuthorization,
|
|
2507
2510
|
releaseConnectionAuthorization,
|
|
2511
|
+
async saveContent(data: {
|
|
2512
|
+
documentName: string
|
|
2513
|
+
document: Y.Doc
|
|
2514
|
+
context: CollabContext
|
|
2515
|
+
store: (confirmPersistence: (snapshot: Y.Snapshot) => void) => Promise<unknown>
|
|
2516
|
+
}): Promise<boolean> {
|
|
2517
|
+
try {
|
|
2518
|
+
assertScopedContext(data.context)
|
|
2519
|
+
const scope = roomScopes.get(data.document)
|
|
2520
|
+
const isCurrent = () => data.context.documentId === data.documentName
|
|
2521
|
+
&& scope?.tenantId === data.context.tenantId
|
|
2522
|
+
&& scope.organizationId === data.context.organizationId
|
|
2523
|
+
&& !data.context.readOnly
|
|
2524
|
+
&& !isInvalidated(data.documentName, data.document)
|
|
2525
|
+
&& !isFinalDrainPending(data.documentName)
|
|
2526
|
+
if (!isCurrent() || !(await deps.authorizeContext(data.context)) || !isCurrent()) return false
|
|
2527
|
+
const requested = Y.snapshot(data.document)
|
|
2528
|
+
const confirmation: { snapshot: Y.Snapshot | null } = { snapshot: null }
|
|
2529
|
+
await data.store((snapshot) => { confirmation.snapshot = snapshot })
|
|
2530
|
+
if (!isCurrent() || !(await deps.authorizeContext(data.context)) || !isCurrent()) return false
|
|
2531
|
+
const durable = confirmation.snapshot
|
|
2532
|
+
return Boolean(durable && containsDocumentSnapshot(durable, requested))
|
|
2533
|
+
} catch {
|
|
2534
|
+
return false
|
|
2535
|
+
}
|
|
2536
|
+
},
|
|
2508
2537
|
reauthorizeActiveConnection,
|
|
2509
2538
|
recordRedisAggregate(document: Y.Doc, byteLength: number): void {
|
|
2510
2539
|
const previous = roomResourceBudgets.get(document)
|
|
@@ -2747,6 +2776,7 @@ export function createCollabHooks(deps: CollabHooksDeps) {
|
|
|
2747
2776
|
documentName: string
|
|
2748
2777
|
context: CollabContext
|
|
2749
2778
|
document: Y.Doc
|
|
2779
|
+
confirmPersistence?: (snapshot: Y.Snapshot) => void
|
|
2750
2780
|
}): Promise<void> {
|
|
2751
2781
|
try {
|
|
2752
2782
|
assertScopedContext(data.context)
|
|
@@ -2833,6 +2863,7 @@ export function createCollabHooks(deps: CollabHooksDeps) {
|
|
|
2833
2863
|
logger.warn('materialization failed; preserving previous html/text', { room: data.documentName })
|
|
2834
2864
|
}
|
|
2835
2865
|
const yjsState = Buffer.from(Y.encodeStateAsUpdate(data.document))
|
|
2866
|
+
const storingSnapshot = Y.snapshot(data.document)
|
|
2836
2867
|
const resourceBudgetRevision = roomResourceBudgets.get(data.document)?.revision ?? 0
|
|
2837
2868
|
assertDocumentContentResourceLimits({
|
|
2838
2869
|
yjsState,
|
|
@@ -2950,6 +2981,7 @@ export function createCollabHooks(deps: CollabHooksDeps) {
|
|
|
2950
2981
|
})
|
|
2951
2982
|
}
|
|
2952
2983
|
deps.onPersisted?.(data.document, yjsState, collaborationGeneration)
|
|
2984
|
+
if (materialized) data.confirmPersistence?.(storingSnapshot)
|
|
2953
2985
|
if (finalDrainAuthorized) deps.finalDrainRegistry?.complete(data.document)
|
|
2954
2986
|
return
|
|
2955
2987
|
}
|
|
@@ -3141,6 +3173,8 @@ export async function main(): Promise<void> {
|
|
|
3141
3173
|
const finalDrainRegistry = createCollabFinalDrainRegistry()
|
|
3142
3174
|
let server: HocuspocusServer<CollabContext> | null = null
|
|
3143
3175
|
let redisStoreExtension: DocumentsCollabRedisExtension | null = null
|
|
3176
|
+
const savingConnections = new WeakSet<object>()
|
|
3177
|
+
const storeConfirmations = new WeakMap<onStoreDocumentPayload<CollabContext>, (snapshot: Y.Snapshot) => void>()
|
|
3144
3178
|
const closeRoomConnectionsForContentReset = (documentName: string): void => {
|
|
3145
3179
|
if (server) closeCollabRoomConnectionsForContentReset(server.hocuspocus, documentName)
|
|
3146
3180
|
}
|
|
@@ -3325,8 +3359,53 @@ export async function main(): Promise<void> {
|
|
|
3325
3359
|
documentName: data.documentName,
|
|
3326
3360
|
context: data.lastContext,
|
|
3327
3361
|
document: data.document,
|
|
3362
|
+
confirmPersistence: storeConfirmations.get(data),
|
|
3328
3363
|
})
|
|
3329
3364
|
},
|
|
3365
|
+
async onStateless(data: onStatelessPayload) {
|
|
3366
|
+
let value: unknown
|
|
3367
|
+
try { value = JSON.parse(data.payload) } catch { return }
|
|
3368
|
+
const request = collabContentSaveRequestSchema.safeParse(value)
|
|
3369
|
+
if (!request.success) return
|
|
3370
|
+
const respond = (saved: boolean) => data.connection.sendStateless(JSON.stringify({
|
|
3371
|
+
type: 'documents.content.saved',
|
|
3372
|
+
requestId: request.data.requestId,
|
|
3373
|
+
saved,
|
|
3374
|
+
}))
|
|
3375
|
+
if (savingConnections.has(data.connection) || !server) {
|
|
3376
|
+
respond(false)
|
|
3377
|
+
return
|
|
3378
|
+
}
|
|
3379
|
+
savingConnections.add(data.connection)
|
|
3380
|
+
try {
|
|
3381
|
+
const runtime = server.hocuspocus
|
|
3382
|
+
const context: CollabContext = data.connection.context
|
|
3383
|
+
const saved = await hooks.saveContent({
|
|
3384
|
+
documentName: data.documentName,
|
|
3385
|
+
document: data.document,
|
|
3386
|
+
context,
|
|
3387
|
+
store: async (confirmPersistence) => {
|
|
3388
|
+
const payload: onStoreDocumentPayload<CollabContext> = {
|
|
3389
|
+
instance: runtime,
|
|
3390
|
+
document: data.document,
|
|
3391
|
+
documentName: data.documentName,
|
|
3392
|
+
clientsCount: data.document.getConnectionsCount(),
|
|
3393
|
+
lastContext: context,
|
|
3394
|
+
lastTransactionOrigin: { source: 'local', context },
|
|
3395
|
+
}
|
|
3396
|
+
storeConfirmations.set(payload, confirmPersistence)
|
|
3397
|
+
try {
|
|
3398
|
+
await runtime.storeDocumentHooks(data.document, payload, true)
|
|
3399
|
+
} finally {
|
|
3400
|
+
storeConfirmations.delete(payload)
|
|
3401
|
+
}
|
|
3402
|
+
},
|
|
3403
|
+
})
|
|
3404
|
+
respond(saved)
|
|
3405
|
+
} finally {
|
|
3406
|
+
savingConnections.delete(data.connection)
|
|
3407
|
+
}
|
|
3408
|
+
},
|
|
3330
3409
|
async onRequest(data: onRequestPayload) {
|
|
3331
3410
|
return handleCollabServerRequest(data)
|
|
3332
3411
|
},
|
|
@@ -19,6 +19,7 @@ import { useDocumentEditor } from './useDocumentEditor'
|
|
|
19
19
|
import { useFallbackContentPersistence } from './useFallbackContentPersistence'
|
|
20
20
|
import { useDocumentTitle } from './useDocumentTitle'
|
|
21
21
|
import { useEditorInsertions } from './useEditorInsertions'
|
|
22
|
+
import { saveCollaborativeContent } from './saveCollaborativeContent'
|
|
22
23
|
|
|
23
24
|
type Props = DocumentEditorIslandProps & {
|
|
24
25
|
transport: 'collab' | 'fallback'
|
|
@@ -62,6 +63,21 @@ export function DocumentEditorSurface(input: Props) {
|
|
|
62
63
|
readOnly: effectiveReadOnly,
|
|
63
64
|
onTitleChange: input.onTitleChange,
|
|
64
65
|
})
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
const controller = new AbortController()
|
|
68
|
+
input.onSaveReady?.(async () => {
|
|
69
|
+
if (controller.signal.aborted || input.readOnly || !editorModel.editorRef.current) return false
|
|
70
|
+
return input.transport === 'fallback'
|
|
71
|
+
? fallbackPersistence.saveNow()
|
|
72
|
+
: input.collabResources
|
|
73
|
+
? saveCollaborativeContent(input.collabResources, controller.signal)
|
|
74
|
+
: false
|
|
75
|
+
})
|
|
76
|
+
return () => {
|
|
77
|
+
controller.abort()
|
|
78
|
+
input.onSaveReady?.(null)
|
|
79
|
+
}
|
|
80
|
+
}, [editorModel.editorRef, fallbackPersistence.saveNow, input.collabResources, input.onSaveReady, input.readOnly, input.transport])
|
|
65
81
|
const insertions = useEditorInsertions({
|
|
66
82
|
documentId: input.documentId,
|
|
67
83
|
editorRef: editorModel.editorRef,
|
|
@@ -79,6 +79,17 @@ export function DocumentPageClient({ documentId }: { documentId: string }) {
|
|
|
79
79
|
const scrollVersionsIntoView = React.useRef(false)
|
|
80
80
|
const [editorEpoch, setEditorEpoch] = React.useState(0)
|
|
81
81
|
const [editor, setEditor] = React.useState<Editor | null>(null)
|
|
82
|
+
const contentSaveRef = React.useRef<{ documentId: string; save: () => Promise<boolean> } | null>(null)
|
|
83
|
+
const displayedDocumentId = state.status === 'ready' ? state.document.id : null
|
|
84
|
+
const registerContentSave = React.useCallback((save: (() => Promise<boolean>) | null) => {
|
|
85
|
+
contentSaveRef.current = save && displayedDocumentId ? { documentId: displayedDocumentId, save } : null
|
|
86
|
+
}, [displayedDocumentId])
|
|
87
|
+
const saveBeforeSnapshot = React.useCallback(async () => {
|
|
88
|
+
const registered = contentSaveRef.current
|
|
89
|
+
if (!registered || registered.documentId !== documentId) return false
|
|
90
|
+
const saved = await registered.save()
|
|
91
|
+
return saved && contentSaveRef.current === registered
|
|
92
|
+
}, [documentId])
|
|
82
93
|
const [commentFocusRequest, setCommentFocusRequest] = React.useState<CommentFocusRequest | null>(null)
|
|
83
94
|
const requestSequence = React.useRef(0)
|
|
84
95
|
const mutationContextId = `documents-detail:${documentId}`
|
|
@@ -135,6 +146,7 @@ export function DocumentPageClient({ documentId }: { documentId: string }) {
|
|
|
135
146
|
const nextContent = contentCall.status === 404
|
|
136
147
|
? { contentHtml: '', updatedAt: null }
|
|
137
148
|
: normalizeDocumentContent(contentCall.result)
|
|
149
|
+
contentSaveRef.current = null
|
|
138
150
|
setState((current) => current.status === 'ready' ? {
|
|
139
151
|
...current,
|
|
140
152
|
content: nextContent,
|
|
@@ -411,6 +423,7 @@ export function DocumentPageClient({ documentId }: { documentId: string }) {
|
|
|
411
423
|
documentUpdatedAt={document.updatedAt}
|
|
412
424
|
readOnly={!capabilities.canEdit}
|
|
413
425
|
onEditorReady={setEditor}
|
|
426
|
+
onSaveReady={registerContentSave}
|
|
414
427
|
onContentConflict={handleContentConflict}
|
|
415
428
|
onComment={capabilities.canComment ? (anchor) => setCommentFocusRequest((current) => ({ anchor, requestId: (current?.requestId ?? 0) + 1 })) : undefined}
|
|
416
429
|
onTitleChange={(title, updatedAt) => setState((current) => current.status === 'ready' ? {
|
|
@@ -441,6 +454,7 @@ export function DocumentPageClient({ documentId }: { documentId: string }) {
|
|
|
441
454
|
canRestore={capabilities.canEdit}
|
|
442
455
|
contentUpdatedAt={content.updatedAt}
|
|
443
456
|
onRestored={reloadEditor}
|
|
457
|
+
onBeforeSnapshot={saveBeforeSnapshot}
|
|
444
458
|
/>
|
|
445
459
|
</div>
|
|
446
460
|
) : null}
|
|
@@ -65,6 +65,7 @@ type VersionHistoryPanelProps = {
|
|
|
65
65
|
*/
|
|
66
66
|
contentUpdatedAt?: string | null
|
|
67
67
|
onRestored?: () => void | Promise<void>
|
|
68
|
+
onBeforeSnapshot?: () => Promise<boolean>
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export { resolveVersionRestoreCapability } from './componentCapabilities'
|
|
@@ -75,6 +76,7 @@ export function VersionHistoryPanel({
|
|
|
75
76
|
tier,
|
|
76
77
|
canRestore,
|
|
77
78
|
onRestored,
|
|
79
|
+
onBeforeSnapshot,
|
|
78
80
|
}: VersionHistoryPanelProps) {
|
|
79
81
|
const t = useT()
|
|
80
82
|
const mayRestore = resolveVersionRestoreCapability(canRestore, tier)
|
|
@@ -85,6 +87,7 @@ export function VersionHistoryPanel({
|
|
|
85
87
|
const [restoringVersionId, setRestoringVersionId] = React.useState<string | null>(null)
|
|
86
88
|
const [previewVersionId, setPreviewVersionId] = React.useState<string | null>(null)
|
|
87
89
|
const restoreInFlight = React.useRef(false)
|
|
90
|
+
const snapshotInFlight = React.useRef(false)
|
|
88
91
|
const reloadSequence = React.useRef(0)
|
|
89
92
|
const activeReload = React.useRef<AbortController | null>(null)
|
|
90
93
|
const activeDocumentId = React.useRef<string | null>(null)
|
|
@@ -141,16 +144,25 @@ export function VersionHistoryPanel({
|
|
|
141
144
|
}, [reload])
|
|
142
145
|
|
|
143
146
|
const handleSnapshot = React.useCallback(async () => {
|
|
144
|
-
if (!mayRestore) return
|
|
147
|
+
if (!mayRestore || snapshotInFlight.current) return
|
|
148
|
+
snapshotInFlight.current = true
|
|
145
149
|
setIsCreating(true)
|
|
146
150
|
const nextLabel = label.trim() || null
|
|
147
151
|
try {
|
|
148
152
|
await runMutation({
|
|
149
|
-
operation: () =>
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
operation: async () => {
|
|
154
|
+
if (onBeforeSnapshot && !(await onBeforeSnapshot())) {
|
|
155
|
+
throw new Error(t('documents.versions.error.save'))
|
|
156
|
+
}
|
|
157
|
+
if (currentDocumentIdRef.current !== documentId) {
|
|
158
|
+
throw new Error(t('documents.versions.error.save'))
|
|
159
|
+
}
|
|
160
|
+
return apiCallOrThrow(
|
|
161
|
+
`/api/documents/${encodeURIComponent(documentId)}/versions`,
|
|
162
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: nextLabel }) },
|
|
163
|
+
{ errorMessage: t('documents.versions.error.save') },
|
|
164
|
+
)
|
|
165
|
+
},
|
|
154
166
|
context: { formId: mutationContextId, resourceKind: 'documents.document_version', resourceId: documentId, retryLastMutation },
|
|
155
167
|
mutationPayload: { label: nextLabel },
|
|
156
168
|
})
|
|
@@ -162,8 +174,11 @@ export function VersionHistoryPanel({
|
|
|
162
174
|
} catch (error) {
|
|
163
175
|
if (currentDocumentIdRef.current !== documentId) return
|
|
164
176
|
flash(error instanceof Error ? error.message : t('documents.versions.error.save'), 'error')
|
|
165
|
-
} finally {
|
|
166
|
-
|
|
177
|
+
} finally {
|
|
178
|
+
snapshotInFlight.current = false
|
|
179
|
+
setIsCreating(false)
|
|
180
|
+
}
|
|
181
|
+
}, [documentId, label, mayRestore, mutationContextId, onBeforeSnapshot, reload, retryLastMutation, runMutation, t])
|
|
167
182
|
|
|
168
183
|
const handleRestore = React.useCallback(async (version: VersionPreview) => {
|
|
169
184
|
if (!mayRestore || restoreInFlight.current) return
|
|
@@ -20,6 +20,7 @@ export type DocumentEditorIslandProps = {
|
|
|
20
20
|
documentUpdatedAt?: string | null
|
|
21
21
|
readOnly: boolean
|
|
22
22
|
onEditorReady?: (editor: Editor | null) => void
|
|
23
|
+
onSaveReady?: (save: (() => Promise<boolean>) | null) => void
|
|
23
24
|
onComment?: (anchor: CommentAnchor) => void
|
|
24
25
|
onContentConflict?: () => void
|
|
25
26
|
onTitleChange?: (title: string, updatedAt: string | null) => void
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CollabResources } from './editorTypes'
|
|
2
|
+
import { collabContentSaveResponseSchema } from '../../../data/validators'
|
|
3
|
+
import { COLLAB_CONTENT_SAVE_TIMEOUT_MS } from '../../../lib/collabPersistence'
|
|
4
|
+
|
|
5
|
+
export function saveCollaborativeContent(
|
|
6
|
+
resources: CollabResources,
|
|
7
|
+
signal: AbortSignal,
|
|
8
|
+
): Promise<boolean> {
|
|
9
|
+
const { provider } = resources
|
|
10
|
+
if (signal.aborted || !provider.synced || provider.configuration.websocketProvider.status !== 'connected') {
|
|
11
|
+
return Promise.resolve(false)
|
|
12
|
+
}
|
|
13
|
+
const requestId = crypto.randomUUID()
|
|
14
|
+
return new Promise<boolean>((resolve) => {
|
|
15
|
+
const finish = (saved: boolean) => {
|
|
16
|
+
clearTimeout(timer)
|
|
17
|
+
provider.off('stateless', onStateless)
|
|
18
|
+
provider.off('close', onFailure)
|
|
19
|
+
provider.off('disconnect', onFailure)
|
|
20
|
+
provider.off('destroy', onFailure)
|
|
21
|
+
signal.removeEventListener('abort', onFailure)
|
|
22
|
+
resolve(saved)
|
|
23
|
+
}
|
|
24
|
+
const onFailure = () => finish(false)
|
|
25
|
+
const onStateless = ({ payload }: { payload: string }) => {
|
|
26
|
+
let value: unknown
|
|
27
|
+
try { value = JSON.parse(payload) } catch { return }
|
|
28
|
+
const response = collabContentSaveResponseSchema.safeParse(value)
|
|
29
|
+
if (response.success && response.data.requestId === requestId) finish(response.data.saved)
|
|
30
|
+
}
|
|
31
|
+
const timer = setTimeout(onFailure, COLLAB_CONTENT_SAVE_TIMEOUT_MS)
|
|
32
|
+
provider.on('stateless', onStateless)
|
|
33
|
+
provider.on('close', onFailure)
|
|
34
|
+
provider.on('disconnect', onFailure)
|
|
35
|
+
provider.on('destroy', onFailure)
|
|
36
|
+
signal.addEventListener('abort', onFailure, { once: true })
|
|
37
|
+
try {
|
|
38
|
+
provider.sendStateless(JSON.stringify({ type: 'documents.content.save', requestId }))
|
|
39
|
+
} catch {
|
|
40
|
+
finish(false)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
+
|
|
2
3
|
import {
|
|
3
4
|
DOCUMENTS_MAX_CONTENT_HTML_BYTES,
|
|
4
5
|
DOCUMENTS_MAX_CONTENT_TEXT_BYTES,
|
|
@@ -7,6 +8,17 @@ import {
|
|
|
7
8
|
import { containsCanonicalUuid } from '../lib/displayLabels'
|
|
8
9
|
import { DOCUMENT_VERSION_LABEL_MAX_LENGTH } from '../lib/versionLabels'
|
|
9
10
|
|
|
11
|
+
export const collabContentSaveRequestSchema = z.object({
|
|
12
|
+
type: z.literal('documents.content.save'),
|
|
13
|
+
requestId: z.string().uuid(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const collabContentSaveResponseSchema = z.object({
|
|
17
|
+
type: z.literal('documents.content.saved'),
|
|
18
|
+
requestId: z.string().uuid(),
|
|
19
|
+
saved: z.boolean(),
|
|
20
|
+
})
|
|
21
|
+
|
|
10
22
|
const uuid = () => z.string().uuid('documents.validation.common.invalidUuid')
|
|
11
23
|
|
|
12
24
|
function blankStringToNull(value: unknown): unknown {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as Y from 'yjs'
|
|
2
|
+
|
|
3
|
+
export const COLLAB_CONTENT_SAVE_TIMEOUT_MS = 15000
|
|
4
|
+
|
|
5
|
+
export function containsDocumentSnapshot(durable: Y.Snapshot, requested: Y.Snapshot): boolean {
|
|
6
|
+
for (const [clientId, clock] of requested.sv) {
|
|
7
|
+
if ((durable.sv.get(clientId) ?? 0) < clock) return false
|
|
8
|
+
}
|
|
9
|
+
return Y.equalDeleteSets(Y.mergeDeleteSets([durable.ds, requested.ds]), durable.ds)
|
|
10
|
+
}
|