@open-mercato/documents 0.7.1-develop.7182.1.789943f937 → 0.7.1-develop.7185.1.0f280ef1f1

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.
@@ -4,6 +4,7 @@ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find"
4
4
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
5
5
  import { assertOptimisticLock } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
6
6
  import { OPTIMISTIC_LOCK_CONFLICT_CODE } from "@open-mercato/shared/lib/crud/optimistic-lock-headers";
7
+ import { htmlToPlainText } from "@open-mercato/shared/lib/html/htmlToPlainText";
7
8
  import { createLogger } from "@open-mercato/shared/lib/logger";
8
9
  import { DocumentContent } from "../data/entities.js";
9
10
  import { DOCUMENTS_ENTITY_IDS } from "./constants.js";
@@ -21,33 +22,8 @@ function advanceDocumentCollaborationGeneration(content) {
21
22
  content.collaborationGeneration = current + 1;
22
23
  return content.collaborationGeneration;
23
24
  }
24
- const ENTITY_MAP = {
25
- amp: "&",
26
- lt: "<",
27
- gt: ">",
28
- quot: '"',
29
- apos: "'",
30
- nbsp: " "
31
- };
32
- function decodeHtmlEntities(value) {
33
- return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, token) => {
34
- const named = ENTITY_MAP[token.toLowerCase()];
35
- if (named) return named;
36
- if (token.startsWith("#x") || token.startsWith("#X")) {
37
- const parsed = Number.parseInt(token.slice(2), 16);
38
- return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match;
39
- }
40
- if (token.startsWith("#")) {
41
- const parsed = Number.parseInt(token.slice(1), 10);
42
- return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match;
43
- }
44
- return match;
45
- });
46
- }
47
25
  function deriveContentTextFromHtml(contentHtml) {
48
- return decodeHtmlEntities(
49
- contentHtml.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ").replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6])>/gi, "\n").replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
50
- );
26
+ return htmlToPlainText(contentHtml).replace(/\s+/g, " ").trim();
51
27
  }
52
28
  async function loadDocumentContent(em, documentId, scope) {
53
29
  return findOneWithDecryption(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/documents/lib/contentService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport { randomUUID } from 'node:crypto'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { assertOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DocumentContent } from '../data/entities'\nimport { DOCUMENTS_ENTITY_IDS } from './constants'\nimport { nextDocumentVersion } from './versioning'\nimport { assertDocumentContentResourceLimits } from './resourceLimits'\n\nconst logger = createLogger('documents').child({ component: 'content-service' })\n\nexport type DocumentScope = {\n tenantId: string\n organizationId: string\n}\n\nexport type DocumentContentSearchIndexer = {\n indexRecordById: (params: {\n entityId: string\n recordId: string\n tenantId: string\n organizationId?: string | null\n }) => Promise<unknown>\n}\n\nexport type PersistDocumentContentDeps = {\n searchIndexer?: DocumentContentSearchIndexer | null\n /**\n * Content version observed by the writer before it prepared this mutation.\n * When present, the comparison happens while holding the content-row lock.\n */\n expectedUpdatedAt?: string | Date\n /** Server-owned collaborative lineage observed when the room loaded. */\n expectedCollaborationGeneration?: number\n /** Sidecar CAS must remain active even if user-facing optimistic locks are disabled. */\n requireExpectedVersion?: boolean\n}\n\nexport type PersistedDocumentContent = {\n id: string\n updatedAt: Date\n collaborationGeneration: number\n}\n\nexport function normalizeDocumentCollaborationGeneration(value: unknown): number | null {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1\n ? value\n : null\n}\n\nexport function advanceDocumentCollaborationGeneration(content: DocumentContent): number {\n const current = normalizeDocumentCollaborationGeneration(content.collaborationGeneration)\n if (current === null || current >= Number.MAX_SAFE_INTEGER) {\n throw new Error('[internal] document collaboration generation is invalid')\n }\n content.collaborationGeneration = current + 1\n return content.collaborationGeneration\n}\n\nconst ENTITY_MAP: Record<string, string> = {\n amp: '&',\n lt: '<',\n gt: '>',\n quot: '\"',\n apos: \"'\",\n nbsp: ' ',\n}\n\nfunction decodeHtmlEntities(value: string): string {\n return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, token: string) => {\n const named = ENTITY_MAP[token.toLowerCase()]\n if (named) return named\n if (token.startsWith('#x') || token.startsWith('#X')) {\n const parsed = Number.parseInt(token.slice(2), 16)\n return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match\n }\n if (token.startsWith('#')) {\n const parsed = Number.parseInt(token.slice(1), 10)\n return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match\n }\n return match\n })\n}\n\nexport function deriveContentTextFromHtml(contentHtml: string): string {\n return decodeHtmlEntities(\n contentHtml\n .replace(/<script[\\s\\S]*?<\\/script>/gi, ' ')\n .replace(/<style[\\s\\S]*?<\\/style>/gi, ' ')\n .replace(/<\\/(p|div|section|article|header|footer|li|tr|h[1-6])>/gi, '\\n')\n .replace(/<br\\s*\\/?>/gi, '\\n')\n .replace(/<[^>]+>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim(),\n )\n}\n\nexport async function loadDocumentContent(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<DocumentContent | null> {\n return findOneWithDecryption(\n em,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Read the exact collaboration state/version/generation tuple from an\n * isolated transaction. The short pessimistic read lock prevents observing a\n * partially completed authoritative reset while a room is being loaded or a\n * competing replica is reconciling after a CAS loss.\n */\nexport async function loadDocumentContentForCollaboration(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<DocumentContent | null> {\n return em.fork().transactional((transactionalEm) => findOneWithDecryption(\n transactionalEm,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n { lockMode: LockMode.PESSIMISTIC_READ },\n scope,\n ))\n}\n\n/**\n * Lightweight active-room reconciliation probe. A missing row means the\n * scoped content was deleted; normal collaborative stores retain the same\n * generation while authoritative replacements advance it.\n */\nexport async function loadDocumentCollaborationGeneration(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<number | null> {\n const content = await findOneWithDecryption(\n em,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n { fields: ['collaborationGeneration'] },\n scope,\n )\n if (!content) return null\n return normalizeDocumentCollaborationGeneration(content.collaborationGeneration)\n}\n\nexport async function mutateDocumentContentState(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n input: { yjsState?: Buffer | null; contentHtml?: string | null; contentText?: string | null },\n options: { id?: string; now?: Date; existingContent?: DocumentContent | null } = {},\n): Promise<DocumentContent> {\n assertDocumentContentResourceLimits(input)\n let content = options.existingContent !== undefined\n ? options.existingContent\n : await findOneWithDecryption(\n em,\n DocumentContent,\n { documentId, tenantId: scope.tenantId, organizationId: scope.organizationId },\n { filters: false },\n scope,\n )\n const requestedNow = options.now ?? new Date()\n const now = content ? nextDocumentVersion(content.updatedAt, requestedNow) : requestedNow\n if (!content) {\n content = em.create(DocumentContent, {\n id: options.id ?? randomUUID(),\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n contentHtml: '',\n contentText: '',\n collaborationGeneration: 1,\n updatedAt: now,\n })\n em.persist(content)\n } else if (options.id && content.id !== options.id) {\n throw new Error('[internal] document content identity does not match the prepared aggregate')\n }\n\n const hasContentHtml = Object.prototype.hasOwnProperty.call(input, 'contentHtml')\n const hasContentText = Object.prototype.hasOwnProperty.call(input, 'contentText')\n if (hasContentHtml) content.contentHtml = input.contentHtml ?? ''\n if (hasContentText) {\n content.contentText = input.contentText ?? deriveContentTextFromHtml(content.contentHtml ?? '')\n } else if (hasContentHtml) {\n content.contentText = deriveContentTextFromHtml(content.contentHtml ?? '')\n }\n if (Object.prototype.hasOwnProperty.call(input, 'yjsState')) {\n content.yjsState = input.yjsState ?? null\n }\n assertDocumentContentResourceLimits({\n yjsState: content.yjsState,\n contentHtml: content.contentHtml,\n contentText: content.contentText,\n })\n content.deletedAt = null\n content.updatedAt = now\n return content\n}\n\nexport async function persistDocumentContent(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n input: { yjsState?: Buffer | null; contentHtml?: string | null; contentText?: string | null },\n deps: PersistDocumentContentDeps,\n): Promise<PersistedDocumentContent> {\n // Collaboration stores must not reuse a request identity map: every attempt\n // gets a fresh EM, transaction, and row lock before checking both CAS axes.\n const persisted = await em.fork().transactional(async (transactionalEm) => {\n const current = await findOneWithDecryption(\n transactionalEm,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n },\n { filters: false, lockMode: LockMode.PESSIMISTIC_WRITE },\n scope,\n )\n\n if (deps.requireExpectedVersion && deps.expectedUpdatedAt === undefined) {\n throw new Error('[internal] documents content CAS requires an expected version')\n }\n if (\n deps.requireExpectedVersion\n && normalizeDocumentCollaborationGeneration(deps.expectedCollaborationGeneration) === null\n ) {\n throw new Error('[internal] documents content CAS requires an expected collaboration generation')\n }\n if (deps.expectedCollaborationGeneration !== undefined) {\n const currentGeneration = normalizeDocumentCollaborationGeneration(\n current?.collaborationGeneration,\n )\n if (currentGeneration === null || currentGeneration !== deps.expectedCollaborationGeneration) {\n throw new CrudHttpError(409, {\n error: 'Record changed by another user',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentCollaborationGeneration: currentGeneration,\n expectedCollaborationGeneration: deps.expectedCollaborationGeneration,\n })\n }\n }\n if (deps.expectedUpdatedAt !== undefined) {\n if (!current) {\n throw new Error('[internal] documents content changed after the collaboration room loaded')\n }\n assertOptimisticLock({\n resourceKind: DOCUMENTS_ENTITY_IDS.documentContent,\n resourceId: current.id,\n current: current.updatedAt,\n expected: deps.expectedUpdatedAt,\n ...(deps.requireExpectedVersion ? { envValue: 'all' } : {}),\n })\n }\n\n const previousUpdatedAt = current?.updatedAt.getTime() ?? null\n const content = await mutateDocumentContentState(\n transactionalEm,\n documentId,\n scope,\n input,\n { existingContent: current },\n )\n await transactionalEm.flush()\n let persistedUpdatedAt = new Date(content.updatedAt.getTime())\n if (previousUpdatedAt !== null && persistedUpdatedAt.getTime() <= previousUpdatedAt) {\n persistedUpdatedAt = new Date(previousUpdatedAt + 1)\n const affected = await transactionalEm.nativeUpdate(\n DocumentContent,\n {\n id: content.id,\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n },\n { updatedAt: persistedUpdatedAt },\n )\n if (affected !== 1) {\n throw new Error('[internal] documents content version could not be advanced')\n }\n content.updatedAt = persistedUpdatedAt\n }\n const collaborationGeneration = normalizeDocumentCollaborationGeneration(\n content.collaborationGeneration,\n )\n if (collaborationGeneration === null) {\n throw new Error('[internal] document collaboration generation is invalid')\n }\n return { id: content.id, updatedAt: persistedUpdatedAt, collaborationGeneration }\n })\n\n try {\n await deps.searchIndexer?.indexRecordById({\n entityId: DOCUMENTS_ENTITY_IDS.document,\n recordId: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n } catch (error) {\n // The content transaction is already committed. Search is a retryable\n // projection and must not make Hocuspocus retain a stale CAS token or tell\n // an HTTP caller that the canonical write failed.\n logger.error('Content search indexing failed after commit', {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n err: error,\n })\n }\n return persisted\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport { randomUUID } from 'node:crypto'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { assertOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { DocumentContent } from '../data/entities'\nimport { DOCUMENTS_ENTITY_IDS } from './constants'\nimport { nextDocumentVersion } from './versioning'\nimport { assertDocumentContentResourceLimits } from './resourceLimits'\n\nconst logger = createLogger('documents').child({ component: 'content-service' })\n\nexport type DocumentScope = {\n tenantId: string\n organizationId: string\n}\n\nexport type DocumentContentSearchIndexer = {\n indexRecordById: (params: {\n entityId: string\n recordId: string\n tenantId: string\n organizationId?: string | null\n }) => Promise<unknown>\n}\n\nexport type PersistDocumentContentDeps = {\n searchIndexer?: DocumentContentSearchIndexer | null\n /**\n * Content version observed by the writer before it prepared this mutation.\n * When present, the comparison happens while holding the content-row lock.\n */\n expectedUpdatedAt?: string | Date\n /** Server-owned collaborative lineage observed when the room loaded. */\n expectedCollaborationGeneration?: number\n /** Sidecar CAS must remain active even if user-facing optimistic locks are disabled. */\n requireExpectedVersion?: boolean\n}\n\nexport type PersistedDocumentContent = {\n id: string\n updatedAt: Date\n collaborationGeneration: number\n}\n\nexport function normalizeDocumentCollaborationGeneration(value: unknown): number | null {\n return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1\n ? value\n : null\n}\n\nexport function advanceDocumentCollaborationGeneration(content: DocumentContent): number {\n const current = normalizeDocumentCollaborationGeneration(content.collaborationGeneration)\n if (current === null || current >= Number.MAX_SAFE_INTEGER) {\n throw new Error('[internal] document collaboration generation is invalid')\n }\n content.collaborationGeneration = current + 1\n return content.collaborationGeneration\n}\n\nexport function deriveContentTextFromHtml(contentHtml: string): string {\n return htmlToPlainText(contentHtml).replace(/\\s+/g, ' ').trim()\n}\n\nexport async function loadDocumentContent(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<DocumentContent | null> {\n return findOneWithDecryption(\n em,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n undefined,\n scope,\n )\n}\n\n/**\n * Read the exact collaboration state/version/generation tuple from an\n * isolated transaction. The short pessimistic read lock prevents observing a\n * partially completed authoritative reset while a room is being loaded or a\n * competing replica is reconciling after a CAS loss.\n */\nexport async function loadDocumentContentForCollaboration(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<DocumentContent | null> {\n return em.fork().transactional((transactionalEm) => findOneWithDecryption(\n transactionalEm,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n { lockMode: LockMode.PESSIMISTIC_READ },\n scope,\n ))\n}\n\n/**\n * Lightweight active-room reconciliation probe. A missing row means the\n * scoped content was deleted; normal collaborative stores retain the same\n * generation while authoritative replacements advance it.\n */\nexport async function loadDocumentCollaborationGeneration(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n): Promise<number | null> {\n const content = await findOneWithDecryption(\n em,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n deletedAt: null,\n },\n { fields: ['collaborationGeneration'] },\n scope,\n )\n if (!content) return null\n return normalizeDocumentCollaborationGeneration(content.collaborationGeneration)\n}\n\nexport async function mutateDocumentContentState(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n input: { yjsState?: Buffer | null; contentHtml?: string | null; contentText?: string | null },\n options: { id?: string; now?: Date; existingContent?: DocumentContent | null } = {},\n): Promise<DocumentContent> {\n assertDocumentContentResourceLimits(input)\n let content = options.existingContent !== undefined\n ? options.existingContent\n : await findOneWithDecryption(\n em,\n DocumentContent,\n { documentId, tenantId: scope.tenantId, organizationId: scope.organizationId },\n { filters: false },\n scope,\n )\n const requestedNow = options.now ?? new Date()\n const now = content ? nextDocumentVersion(content.updatedAt, requestedNow) : requestedNow\n if (!content) {\n content = em.create(DocumentContent, {\n id: options.id ?? randomUUID(),\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n contentHtml: '',\n contentText: '',\n collaborationGeneration: 1,\n updatedAt: now,\n })\n em.persist(content)\n } else if (options.id && content.id !== options.id) {\n throw new Error('[internal] document content identity does not match the prepared aggregate')\n }\n\n const hasContentHtml = Object.prototype.hasOwnProperty.call(input, 'contentHtml')\n const hasContentText = Object.prototype.hasOwnProperty.call(input, 'contentText')\n if (hasContentHtml) content.contentHtml = input.contentHtml ?? ''\n if (hasContentText) {\n content.contentText = input.contentText ?? deriveContentTextFromHtml(content.contentHtml ?? '')\n } else if (hasContentHtml) {\n content.contentText = deriveContentTextFromHtml(content.contentHtml ?? '')\n }\n if (Object.prototype.hasOwnProperty.call(input, 'yjsState')) {\n content.yjsState = input.yjsState ?? null\n }\n assertDocumentContentResourceLimits({\n yjsState: content.yjsState,\n contentHtml: content.contentHtml,\n contentText: content.contentText,\n })\n content.deletedAt = null\n content.updatedAt = now\n return content\n}\n\nexport async function persistDocumentContent(\n em: EntityManager,\n documentId: string,\n scope: DocumentScope,\n input: { yjsState?: Buffer | null; contentHtml?: string | null; contentText?: string | null },\n deps: PersistDocumentContentDeps,\n): Promise<PersistedDocumentContent> {\n // Collaboration stores must not reuse a request identity map: every attempt\n // gets a fresh EM, transaction, and row lock before checking both CAS axes.\n const persisted = await em.fork().transactional(async (transactionalEm) => {\n const current = await findOneWithDecryption(\n transactionalEm,\n DocumentContent,\n {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n },\n { filters: false, lockMode: LockMode.PESSIMISTIC_WRITE },\n scope,\n )\n\n if (deps.requireExpectedVersion && deps.expectedUpdatedAt === undefined) {\n throw new Error('[internal] documents content CAS requires an expected version')\n }\n if (\n deps.requireExpectedVersion\n && normalizeDocumentCollaborationGeneration(deps.expectedCollaborationGeneration) === null\n ) {\n throw new Error('[internal] documents content CAS requires an expected collaboration generation')\n }\n if (deps.expectedCollaborationGeneration !== undefined) {\n const currentGeneration = normalizeDocumentCollaborationGeneration(\n current?.collaborationGeneration,\n )\n if (currentGeneration === null || currentGeneration !== deps.expectedCollaborationGeneration) {\n throw new CrudHttpError(409, {\n error: 'Record changed by another user',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentCollaborationGeneration: currentGeneration,\n expectedCollaborationGeneration: deps.expectedCollaborationGeneration,\n })\n }\n }\n if (deps.expectedUpdatedAt !== undefined) {\n if (!current) {\n throw new Error('[internal] documents content changed after the collaboration room loaded')\n }\n assertOptimisticLock({\n resourceKind: DOCUMENTS_ENTITY_IDS.documentContent,\n resourceId: current.id,\n current: current.updatedAt,\n expected: deps.expectedUpdatedAt,\n ...(deps.requireExpectedVersion ? { envValue: 'all' } : {}),\n })\n }\n\n const previousUpdatedAt = current?.updatedAt.getTime() ?? null\n const content = await mutateDocumentContentState(\n transactionalEm,\n documentId,\n scope,\n input,\n { existingContent: current },\n )\n await transactionalEm.flush()\n let persistedUpdatedAt = new Date(content.updatedAt.getTime())\n if (previousUpdatedAt !== null && persistedUpdatedAt.getTime() <= previousUpdatedAt) {\n persistedUpdatedAt = new Date(previousUpdatedAt + 1)\n const affected = await transactionalEm.nativeUpdate(\n DocumentContent,\n {\n id: content.id,\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n },\n { updatedAt: persistedUpdatedAt },\n )\n if (affected !== 1) {\n throw new Error('[internal] documents content version could not be advanced')\n }\n content.updatedAt = persistedUpdatedAt\n }\n const collaborationGeneration = normalizeDocumentCollaborationGeneration(\n content.collaborationGeneration,\n )\n if (collaborationGeneration === null) {\n throw new Error('[internal] document collaboration generation is invalid')\n }\n return { id: content.id, updatedAt: persistedUpdatedAt, collaborationGeneration }\n })\n\n try {\n await deps.searchIndexer?.indexRecordById({\n entityId: DOCUMENTS_ENTITY_IDS.document,\n recordId: documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n })\n } catch (error) {\n // The content transaction is already committed. Search is a retryable\n // projection and must not make Hocuspocus retain a stale CAS token or tell\n // an HTTP caller that the canonical write failed.\n logger.error('Content search indexing failed after commit', {\n documentId,\n tenantId: scope.tenantId,\n organizationId: scope.organizationId,\n err: error,\n })\n }\n return persisted\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import * as Y from "yjs";
2
2
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
3
+ import { htmlToPlainText } from "@open-mercato/shared/lib/html/htmlToPlainText";
3
4
  import { sanitizeRichTextHtml } from "@open-mercato/shared/lib/html/sanitizeRichText";
4
5
  import { htmlToYDoc, yDocToContent } from "./collabMaterializer.js";
5
6
  import {
@@ -45,7 +46,7 @@ function normalizeDocumentEntityRefLabels(contentHtml, fallbackLabel) {
45
46
  if (!/(?:^|\s)data-entity-ref(?:\s|=|$)/i.test(attributes)) return whole;
46
47
  const markedInvalid = /(?:^|\s)data-entity-label-invalid(?:\s|=|$)/i.test(attributes);
47
48
  const attributeLabel = readAttribute(attributes, "data-label");
48
- const innerText = decodeCandidate(sanitizeRichTextHtml(innerHtml).replace(/<[^>]*>/g, ""));
49
+ const innerText = decodeCandidate(htmlToPlainText(sanitizeRichTextHtml(innerHtml)));
49
50
  const label = markedInvalid ? readableFallback : firstSafeDocumentsDisplayLabel(attributeLabel, innerText, readableFallback);
50
51
  return `<span>${escapeText(label)}</span>`;
51
52
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/documents/lib/versionContent.ts"],
4
- "sourcesContent": ["import * as Y from 'yjs'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText'\nimport { htmlToYDoc, yDocToContent } from './collabMaterializer'\nimport {\n firstSafeDocumentsDisplayLabel,\n sanitizeDocumentsDisplayLabel,\n} from './displayLabels'\nimport { assertDocumentContentResourceLimits } from './resourceLimits'\n\nexport type MaterializedVersionContent = {\n yjsState: Buffer\n contentHtml: string\n contentText: string\n}\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isDocumentAttachmentSource(src: string, documentId: string): boolean {\n if (!UUID_PATTERN.test(documentId)) return false\n const match = /^\\/api\\/documents\\/([^/]+)\\/attachments\\/([^/]+)$/.exec(src)\n if (!match || !UUID_PATTERN.test(match[1]!) || !UUID_PATTERN.test(match[2]!)) return false\n return match[1]!.toLowerCase() === documentId.toLowerCase()\n}\n\nexport function normalizeDocumentEntityRefLabels(\n contentHtml: string,\n fallbackLabel: string,\n): string {\n const readableFallback = sanitizeDocumentsDisplayLabel(fallbackLabel)\n if (!readableFallback) {\n throw new Error('[internal] document entity references require a readable fallback label')\n }\n const decodeCandidate = (value: string): string | null => {\n try {\n const decoded = value\n .replace(/&#x([0-9a-f]+);/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))\n .replace(/&#([0-9]+);/g, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 10)))\n .replace(/&(amp|quot|apos|lt|gt|nbsp);/gi, (_match, entity: string) => ({\n amp: '&', quot: '\"', apos: \"'\", lt: '<', gt: '>', nbsp: ' ',\n })[entity.toLowerCase()] ?? '')\n return /&(?:#\\d+|#x[0-9a-f]+|[a-z][a-z0-9]+);/i.test(decoded) ? null : decoded\n } catch {\n return null\n }\n }\n const readAttribute = (attributes: string, name: string): string | null => {\n const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const match = new RegExp(`(?:^|\\\\s)${escaped}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s>]+))`, 'i').exec(attributes)\n const raw = match?.[1] ?? match?.[2] ?? match?.[3]\n return raw === undefined ? null : decodeCandidate(raw)\n }\n const escapeText = (value: string): string => value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n\n return contentHtml.replace(/<span\\b([^>]*)>([\\s\\S]*?)(?:<\\/span\\s*>|$)/gi, (whole, attributes: string, innerHtml: string) => {\n if (!/(?:^|\\s)data-entity-ref(?:\\s|=|$)/i.test(attributes)) return whole\n const markedInvalid = /(?:^|\\s)data-entity-label-invalid(?:\\s|=|$)/i.test(attributes)\n const attributeLabel = readAttribute(attributes, 'data-label')\n const innerText = decodeCandidate(sanitizeRichTextHtml(innerHtml).replace(/<[^>]*>/g, ''))\n const label = markedInvalid\n ? readableFallback\n : firstSafeDocumentsDisplayLabel(attributeLabel, innerText, readableFallback)!\n return `<span>${escapeText(label)}</span>`\n })\n}\n\n/**\n * The shared sanitizer deliberately permits https images for other rich-text\n * surfaces. Documents previews are stricter: they must not make any network\n * request while rendering historical content, so only the current document's\n * relative attachment endpoint survives.\n */\nexport function sanitizeDocumentPreviewHtml(\n contentHtml: string,\n documentId: string,\n entityRefFallbackLabel = 'Record',\n): string {\n assertDocumentContentResourceLimits(\n { contentHtml },\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n const sanitized = sanitizeRichTextHtml(\n normalizeDocumentEntityRefLabels(contentHtml, entityRefFallbackLabel),\n )\n const withoutExternalImages = sanitized.replace(/<img\\b[^>]*>/gi, (tag) => {\n const match = /\\ssrc=(?:\"([^\"]*)\"|'([^']*)')/i.exec(tag)\n const src = match?.[1] ?? match?.[2] ?? ''\n return isDocumentAttachmentSource(src, documentId) ? tag : ''\n })\n try {\n const normalized = yDocToContent(htmlToYDoc(withoutExternalImages))\n if (!normalized) throw new Error('[internal] Unable to normalize document preview')\n return normalized.html\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.previewInvalid' })\n }\n}\n\nexport function materializeDocumentVersion(input: {\n yjsSnapshot: Buffer | Uint8Array\n contentHtml: string | null | undefined\n}): MaterializedVersionContent {\n assertDocumentContentResourceLimits(\n { yjsState: input.yjsSnapshot, contentHtml: input.contentHtml },\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n let ydoc: Y.Doc\n const snapshot = Buffer.from(input.yjsSnapshot)\n if (snapshot.length === 0) {\n try {\n ydoc = htmlToYDoc(input.contentHtml ?? '')\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.previewInvalid' })\n }\n } else {\n ydoc = new Y.Doc()\n try {\n Y.applyUpdate(ydoc, new Uint8Array(snapshot))\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.corruptSnapshot' })\n }\n }\n\n const materialized = yDocToContent(ydoc)\n if (!materialized) {\n throw new CrudHttpError(422, { error: 'documents.versions.corruptSnapshot' })\n }\n const result = {\n yjsState: Buffer.from(Y.encodeStateAsUpdate(ydoc)),\n contentHtml: materialized.html,\n contentText: materialized.text,\n }\n assertDocumentContentResourceLimits(\n result,\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n return result\n}\n\nexport function materializeDocumentVersionPreview(input: {\n documentId: string\n yjsSnapshot: Buffer | Uint8Array\n contentHtml: string | null | undefined\n entityRefFallbackLabel?: string\n}): string {\n if (Buffer.from(input.yjsSnapshot).length === 0) {\n // Legacy HTML must be sanitized before it ever enters the editor parser.\n return sanitizeDocumentPreviewHtml(\n input.contentHtml ?? '',\n input.documentId,\n input.entityRefFallbackLabel,\n )\n }\n const materialized = materializeDocumentVersion(input)\n return sanitizeDocumentPreviewHtml(\n materialized.contentHtml,\n input.documentId,\n input.entityRefFallbackLabel,\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as Y from 'yjs'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'\nimport { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText'\nimport { htmlToYDoc, yDocToContent } from './collabMaterializer'\nimport {\n firstSafeDocumentsDisplayLabel,\n sanitizeDocumentsDisplayLabel,\n} from './displayLabels'\nimport { assertDocumentContentResourceLimits } from './resourceLimits'\n\nexport type MaterializedVersionContent = {\n yjsState: Buffer\n contentHtml: string\n contentText: string\n}\n\nconst UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isDocumentAttachmentSource(src: string, documentId: string): boolean {\n if (!UUID_PATTERN.test(documentId)) return false\n const match = /^\\/api\\/documents\\/([^/]+)\\/attachments\\/([^/]+)$/.exec(src)\n if (!match || !UUID_PATTERN.test(match[1]!) || !UUID_PATTERN.test(match[2]!)) return false\n return match[1]!.toLowerCase() === documentId.toLowerCase()\n}\n\nexport function normalizeDocumentEntityRefLabels(\n contentHtml: string,\n fallbackLabel: string,\n): string {\n const readableFallback = sanitizeDocumentsDisplayLabel(fallbackLabel)\n if (!readableFallback) {\n throw new Error('[internal] document entity references require a readable fallback label')\n }\n const decodeCandidate = (value: string): string | null => {\n try {\n const decoded = value\n .replace(/&#x([0-9a-f]+);/gi, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))\n .replace(/&#([0-9]+);/g, (_match, code: string) => String.fromCodePoint(Number.parseInt(code, 10)))\n .replace(/&(amp|quot|apos|lt|gt|nbsp);/gi, (_match, entity: string) => ({\n amp: '&', quot: '\"', apos: \"'\", lt: '<', gt: '>', nbsp: ' ',\n })[entity.toLowerCase()] ?? '')\n return /&(?:#\\d+|#x[0-9a-f]+|[a-z][a-z0-9]+);/i.test(decoded) ? null : decoded\n } catch {\n return null\n }\n }\n const readAttribute = (attributes: string, name: string): string | null => {\n const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const match = new RegExp(`(?:^|\\\\s)${escaped}\\\\s*=\\\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\\\s>]+))`, 'i').exec(attributes)\n const raw = match?.[1] ?? match?.[2] ?? match?.[3]\n return raw === undefined ? null : decodeCandidate(raw)\n }\n const escapeText = (value: string): string => value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n\n return contentHtml.replace(/<span\\b([^>]*)>([\\s\\S]*?)(?:<\\/span\\s*>|$)/gi, (whole, attributes: string, innerHtml: string) => {\n if (!/(?:^|\\s)data-entity-ref(?:\\s|=|$)/i.test(attributes)) return whole\n const markedInvalid = /(?:^|\\s)data-entity-label-invalid(?:\\s|=|$)/i.test(attributes)\n const attributeLabel = readAttribute(attributes, 'data-label')\n const innerText = decodeCandidate(htmlToPlainText(sanitizeRichTextHtml(innerHtml)))\n const label = markedInvalid\n ? readableFallback\n : firstSafeDocumentsDisplayLabel(attributeLabel, innerText, readableFallback)!\n return `<span>${escapeText(label)}</span>`\n })\n}\n\n/**\n * The shared sanitizer deliberately permits https images for other rich-text\n * surfaces. Documents previews are stricter: they must not make any network\n * request while rendering historical content, so only the current document's\n * relative attachment endpoint survives.\n */\nexport function sanitizeDocumentPreviewHtml(\n contentHtml: string,\n documentId: string,\n entityRefFallbackLabel = 'Record',\n): string {\n assertDocumentContentResourceLimits(\n { contentHtml },\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n const sanitized = sanitizeRichTextHtml(\n normalizeDocumentEntityRefLabels(contentHtml, entityRefFallbackLabel),\n )\n const withoutExternalImages = sanitized.replace(/<img\\b[^>]*>/gi, (tag) => {\n const match = /\\ssrc=(?:\"([^\"]*)\"|'([^']*)')/i.exec(tag)\n const src = match?.[1] ?? match?.[2] ?? ''\n return isDocumentAttachmentSource(src, documentId) ? tag : ''\n })\n try {\n const normalized = yDocToContent(htmlToYDoc(withoutExternalImages))\n if (!normalized) throw new Error('[internal] Unable to normalize document preview')\n return normalized.html\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.previewInvalid' })\n }\n}\n\nexport function materializeDocumentVersion(input: {\n yjsSnapshot: Buffer | Uint8Array\n contentHtml: string | null | undefined\n}): MaterializedVersionContent {\n assertDocumentContentResourceLimits(\n { yjsState: input.yjsSnapshot, contentHtml: input.contentHtml },\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n let ydoc: Y.Doc\n const snapshot = Buffer.from(input.yjsSnapshot)\n if (snapshot.length === 0) {\n try {\n ydoc = htmlToYDoc(input.contentHtml ?? '')\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.previewInvalid' })\n }\n } else {\n ydoc = new Y.Doc()\n try {\n Y.applyUpdate(ydoc, new Uint8Array(snapshot))\n } catch {\n throw new CrudHttpError(422, { error: 'documents.versions.corruptSnapshot' })\n }\n }\n\n const materialized = yDocToContent(ydoc)\n if (!materialized) {\n throw new CrudHttpError(422, { error: 'documents.versions.corruptSnapshot' })\n }\n const result = {\n yjsState: Buffer.from(Y.encodeStateAsUpdate(ydoc)),\n contentHtml: materialized.html,\n contentText: materialized.text,\n }\n assertDocumentContentResourceLimits(\n result,\n { status: 422, error: 'documents.versions.snapshotTooLarge' },\n )\n return result\n}\n\nexport function materializeDocumentVersionPreview(input: {\n documentId: string\n yjsSnapshot: Buffer | Uint8Array\n contentHtml: string | null | undefined\n entityRefFallbackLabel?: string\n}): string {\n if (Buffer.from(input.yjsSnapshot).length === 0) {\n // Legacy HTML must be sanitized before it ever enters the editor parser.\n return sanitizeDocumentPreviewHtml(\n input.contentHtml ?? '',\n input.documentId,\n input.entityRefFallbackLabel,\n )\n }\n const materialized = materializeDocumentVersion(input)\n return sanitizeDocumentPreviewHtml(\n materialized.contentHtml,\n input.documentId,\n input.entityRefFallbackLabel,\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/documents",
3
- "version": "0.7.1-develop.7182.1.789943f937",
3
+ "version": "0.7.1-develop.7185.1.0f280ef1f1",
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.7.1-develop.7182.1.789943f937",
79
- "@open-mercato/ui": "0.7.1-develop.7182.1.789943f937",
78
+ "@open-mercato/events": "0.7.1-develop.7185.1.0f280ef1f1",
79
+ "@open-mercato/ui": "0.7.1-develop.7185.1.0f280ef1f1",
80
80
  "@tiptap/core": "^3",
81
81
  "@tiptap/extension-bubble-menu": "^3",
82
82
  "@tiptap/extension-collaboration": "^3.27.3",
@@ -113,13 +113,13 @@
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@mikro-orm/postgresql": "^7.0.14",
116
- "@open-mercato/shared": "0.7.1-develop.7182.1.789943f937",
116
+ "@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1",
117
117
  "react": "^19.0.0",
118
118
  "react-dom": "^19.0.0"
119
119
  },
120
120
  "devDependencies": {
121
- "@open-mercato/core": "0.7.1-develop.7182.1.789943f937",
122
- "@open-mercato/shared": "0.7.1-develop.7182.1.789943f937",
121
+ "@open-mercato/core": "0.7.1-develop.7185.1.0f280ef1f1",
122
+ "@open-mercato/shared": "0.7.1-develop.7185.1.0f280ef1f1",
123
123
  "@types/jest": "^30.0.0",
124
124
  "@types/pg": "^8.23.1",
125
125
  "@types/react": "^19.2.18",
@@ -5,6 +5,7 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
6
  import { assertOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
7
7
  import { OPTIMISTIC_LOCK_CONFLICT_CODE } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
8
+ import { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'
8
9
  import { createLogger } from '@open-mercato/shared/lib/logger'
9
10
  import { DocumentContent } from '../data/entities'
10
11
  import { DOCUMENTS_ENTITY_IDS } from './constants'
@@ -61,42 +62,8 @@ export function advanceDocumentCollaborationGeneration(content: DocumentContent)
61
62
  return content.collaborationGeneration
62
63
  }
63
64
 
64
- const ENTITY_MAP: Record<string, string> = {
65
- amp: '&',
66
- lt: '<',
67
- gt: '>',
68
- quot: '"',
69
- apos: "'",
70
- nbsp: ' ',
71
- }
72
-
73
- function decodeHtmlEntities(value: string): string {
74
- return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, token: string) => {
75
- const named = ENTITY_MAP[token.toLowerCase()]
76
- if (named) return named
77
- if (token.startsWith('#x') || token.startsWith('#X')) {
78
- const parsed = Number.parseInt(token.slice(2), 16)
79
- return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match
80
- }
81
- if (token.startsWith('#')) {
82
- const parsed = Number.parseInt(token.slice(1), 10)
83
- return Number.isFinite(parsed) ? String.fromCodePoint(parsed) : match
84
- }
85
- return match
86
- })
87
- }
88
-
89
65
  export function deriveContentTextFromHtml(contentHtml: string): string {
90
- return decodeHtmlEntities(
91
- contentHtml
92
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
93
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
94
- .replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6])>/gi, '\n')
95
- .replace(/<br\s*\/?>/gi, '\n')
96
- .replace(/<[^>]+>/g, ' ')
97
- .replace(/\s+/g, ' ')
98
- .trim(),
99
- )
66
+ return htmlToPlainText(contentHtml).replace(/\s+/g, ' ').trim()
100
67
  }
101
68
 
102
69
  export async function loadDocumentContent(
@@ -1,5 +1,6 @@
1
1
  import * as Y from 'yjs'
2
2
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
3
+ import { htmlToPlainText } from '@open-mercato/shared/lib/html/htmlToPlainText'
3
4
  import { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText'
4
5
  import { htmlToYDoc, yDocToContent } from './collabMaterializer'
5
6
  import {
@@ -59,7 +60,7 @@ export function normalizeDocumentEntityRefLabels(
59
60
  if (!/(?:^|\s)data-entity-ref(?:\s|=|$)/i.test(attributes)) return whole
60
61
  const markedInvalid = /(?:^|\s)data-entity-label-invalid(?:\s|=|$)/i.test(attributes)
61
62
  const attributeLabel = readAttribute(attributes, 'data-label')
62
- const innerText = decodeCandidate(sanitizeRichTextHtml(innerHtml).replace(/<[^>]*>/g, ''))
63
+ const innerText = decodeCandidate(htmlToPlainText(sanitizeRichTextHtml(innerHtml)))
63
64
  const label = markedInvalid
64
65
  ? readableFallback
65
66
  : firstSafeDocumentsDisplayLabel(attributeLabel, innerText, readableFallback)!