@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.
Files changed (24) hide show
  1. package/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js +12 -0
  2. package/dist/modules/documents/backend/documents/[id]/DocumentEditorSurface.js.map +2 -2
  3. package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js +15 -1
  4. package/dist/modules/documents/backend/documents/[id]/DocumentPageClient.js.map +2 -2
  5. package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js +20 -8
  6. package/dist/modules/documents/backend/documents/[id]/VersionHistoryPanel.js.map +2 -2
  7. package/dist/modules/documents/backend/documents/[id]/editorTypes.js.map +2 -2
  8. package/dist/modules/documents/backend/documents/[id]/saveCollaborativeContent.js +46 -0
  9. package/dist/modules/documents/backend/documents/[id]/saveCollaborativeContent.js.map +7 -0
  10. package/dist/modules/documents/data/validators.js +11 -0
  11. package/dist/modules/documents/data/validators.js.map +2 -2
  12. package/dist/modules/documents/lib/collabPersistence.js +13 -0
  13. package/dist/modules/documents/lib/collabPersistence.js.map +7 -0
  14. package/dist/server/documents-collab-server.js +74 -1
  15. package/dist/server/documents-collab-server.js.map +2 -2
  16. package/package.json +6 -6
  17. package/server/documents-collab-server.ts +79 -0
  18. package/src/modules/documents/backend/documents/[id]/DocumentEditorSurface.tsx +16 -0
  19. package/src/modules/documents/backend/documents/[id]/DocumentPageClient.tsx +14 -0
  20. package/src/modules/documents/backend/documents/[id]/VersionHistoryPanel.tsx +23 -8
  21. package/src/modules/documents/backend/documents/[id]/editorTypes.ts +1 -0
  22. package/src/modules/documents/backend/documents/[id]/saveCollaborativeContent.ts +43 -0
  23. package/src/modules/documents/data/validators.ts +12 -0
  24. 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.7925.1.ec40f75262",
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.7925.1.ec40f75262",
79
- "@open-mercato/ui": "0.10.1-develop.7925.1.ec40f75262",
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.7925.1.ec40f75262",
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.7925.1.ec40f75262",
121
- "@open-mercato/shared": "0.10.1-develop.7925.1.ec40f75262",
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: () => apiCallOrThrow(
150
- `/api/documents/${encodeURIComponent(documentId)}/versions`,
151
- { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ label: nextLabel }) },
152
- { errorMessage: t('documents.versions.error.save') },
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 { setIsCreating(false) }
166
- }, [documentId, label, mayRestore, mutationContextId, reload, retryLastMutation, runMutation, t])
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
+ }