From c94bbf85a989312fad69241cee05972579d20a52 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 7 Sep 2026 15:00:51 +0200 Subject: [PATCH 01/12] chore(update): use addType with Types:: constants Signed-off-by: Max --- lib/Db/Document.php | 15 ++++++++------- lib/Db/Session.php | 5 +++-- lib/Db/Step.php | 9 +++++---- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/Db/Document.php b/lib/Db/Document.php index 58c0f227265..c550743c609 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -8,6 +8,7 @@ namespace OCA\Text\Db; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method getId(): int @@ -44,13 +45,13 @@ class Document extends Entity implements \JsonSerializable { protected int $contextId = 0; public function __construct() { - $this->addType('currentVersion', 'integer'); - $this->addType('lastSavedVersion', 'integer'); - $this->addType('lastSavedVersionTime', 'integer'); - $this->addType('initialVersion', 'integer'); - $this->addType('checksum', 'string'); - $this->addType('contextType', 'string'); - $this->addType('contextId', 'integer'); + $this->addType('currentVersion', Types::INTEGER); + $this->addType('lastSavedVersion', Types::INTEGER); + $this->addType('lastSavedVersionTime', Types::INTEGER); + $this->addType('initialVersion', Types::INTEGER); + $this->addType('checksum', Types::STRING); + $this->addType('contextType', Types::STRING); + $this->addType('contextId', Types::INTEGER); } public function jsonSerialize(): array { diff --git a/lib/Db/Session.php b/lib/Db/Session.php index 7bcf39d6c48..936dd7eb821 100644 --- a/lib/Db/Session.php +++ b/lib/Db/Session.php @@ -10,6 +10,7 @@ use JsonSerializable; use OCA\Text\Exception\InvalidSessionException; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method void setUserId(?string $userId) @@ -37,8 +38,8 @@ class Session extends Entity implements JsonSerializable { protected int $documentId = 0; public function __construct() { - $this->addType('documentId', 'integer'); - $this->addType('lastContact', 'integer'); + $this->addType('documentId', Types::INTEGER); + $this->addType('lastContact', Types::INTEGER); } public function isGuest(): bool { diff --git a/lib/Db/Step.php b/lib/Db/Step.php index 57b6b2bf2d3..6dc009f2f46 100644 --- a/lib/Db/Step.php +++ b/lib/Db/Step.php @@ -9,6 +9,7 @@ use JsonSerializable; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method getData(): string @@ -39,10 +40,10 @@ class Step extends Entity implements JsonSerializable { protected int $timestamp = 0; public function __construct() { - $this->addType('version', 'integer'); - $this->addType('documentId', 'integer'); - $this->addType('sessionId', 'integer'); - $this->addType('timestamp', 'integer'); + $this->addType('version', Types::INTEGER); + $this->addType('documentId', Types::INTEGER); + $this->addType('sessionId', Types::INTEGER); + $this->addType('timestamp', Types::INTEGER); } public function jsonSerialize(): array { From 4be7b15f00248f3f4b8cb8e3f5c5fae35b841075 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 7 Sep 2026 15:12:14 +0200 Subject: [PATCH 02/12] enh(document): use snowflake ids Do not rely on autoincrement ids, which require shuffling tables around in the migration as some databases cannot change columns to autoincrement. `$document->id` is now a string, which conflicts with the definition in the Entity class. Mute the psalm warning about this for now. Proper solution will be to migrate to the new ORM. https://carlschwan.eu/2026/08/18/modern-stable-apis-for-your-nextcloud-application/ Signed-off-by: Max --- cypress/e2e/api/SessionApi.spec.js | 5 +- cypress/e2e/api/UsersApi.spec.js | 5 +- lib/Command/ResetDocument.php | 2 +- lib/Controller/ISessionAwareController.php | 4 +- lib/Controller/PublicSessionController.php | 2 +- lib/Controller/SessionController.php | 4 +- lib/Controller/TSessionAwareController.php | 6 +- lib/Cron/Cleanup.php | 2 +- lib/Db/Document.php | 13 +++-- lib/Db/DocumentMapper.php | 2 +- lib/Db/Session.php | 8 +-- lib/Db/SessionMapper.php | 14 ++--- lib/Db/Step.php | 8 +-- lib/Db/StepMapper.php | 12 ++-- lib/Middleware/SessionMiddleware.php | 6 +- lib/Service/ApiService.php | 27 ++++++--- lib/Service/AttachmentService.php | 16 +++--- lib/Service/DocumentService.php | 55 ++++++++++--------- lib/Service/SessionService.php | 18 +++--- tests/unit/Db/SessionMapperTest.php | 14 +++-- .../unit/Middleware/SessionMiddlewareTest.php | 10 ++-- tests/unit/Service/ApiServiceTest.php | 3 +- tests/unit/Service/DocumentServiceTest.php | 4 +- 23 files changed, 134 insertions(+), 106 deletions(-) diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 9ceb84a9f7b..8bcdd132dbb 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -37,7 +37,10 @@ describe('The session Api', function() { it('returns connection', function() { cy.openFileConnection({ fileId }).then(({ connection }) => { - cy.wrap(connection).its('documentId').should('be.greaterThan', 0) + cy.wrap(connection) + .its('documentId') + .then((id) => Number.parseInt(id)) + .should('be.greaterThan', 1_000_000) // snowflake ids have more than 20 bit. cy.closeConnection(connection) }) }) diff --git a/cypress/e2e/api/UsersApi.spec.js b/cypress/e2e/api/UsersApi.spec.js index c8ca4cda3d2..c7482f0f46e 100644 --- a/cypress/e2e/api/UsersApi.spec.js +++ b/cypress/e2e/api/UsersApi.spec.js @@ -22,7 +22,10 @@ describe('The user mention API', function() { }) it('has a valid connection', function() { - cy.get('@connection').its('documentId').should('be.greaterThan', 0) + cy.get('@connection') + .its('documentId') + .then((id) => Number.parseInt(id)) + .should('be.greaterThan', 1_000_000) // snowflake ids have more than 20 bit. cy.closeConnection(this.connection) }) diff --git a/lib/Command/ResetDocument.php b/lib/Command/ResetDocument.php index a008dd14391..91d23498be2 100644 --- a/lib/Command/ResetDocument.php +++ b/lib/Command/ResetDocument.php @@ -75,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($all) { $fileIds = []; foreach ($this->documentService->getAll() as $document) { - $fileIds[] = $document->getId(); + $fileIds[] = $document->id; } } else { $fileIds = [$fileId]; diff --git a/lib/Controller/ISessionAwareController.php b/lib/Controller/ISessionAwareController.php index 87ebd86289a..566ddc85c2f 100644 --- a/lib/Controller/ISessionAwareController.php +++ b/lib/Controller/ISessionAwareController.php @@ -14,8 +14,8 @@ interface ISessionAwareController { public function getSession(): Session; public function setSession(Session $session): void; - public function getDocumentId(): int; - public function setDocumentId(int $documentId): void; + public function getDocumentId(): string; + public function setDocumentId(string $documentId): void; public function getDocument(): Document; public function setDocument(Document $document): void; public function getUser(): IUser; diff --git a/lib/Controller/PublicSessionController.php b/lib/Controller/PublicSessionController.php index 1b645edc344..6332b7dd3cb 100644 --- a/lib/Controller/PublicSessionController.php +++ b/lib/Controller/PublicSessionController.php @@ -84,7 +84,7 @@ public function create(string $token, ?string $filePath = null, ?string $baseVer #[NoAdminRequired] #[PublicPage] - public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken): DataResponse { return $this->apiService->close($documentId, $sessionId, $sessionToken, $this->getShare()); } diff --git a/lib/Controller/SessionController.php b/lib/Controller/SessionController.php index 1f66e72f3b0..c18ccb3ba1c 100644 --- a/lib/Controller/SessionController.php +++ b/lib/Controller/SessionController.php @@ -73,7 +73,7 @@ public function create(string $type, int $id, ?string $baseVersionEtag = null): #[NoAdminRequired] #[PublicPage] - public function close(int $documentId, int $sessionId, string $sessionToken): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken): DataResponse { // We also want this to work with a session that has already been closed. // So we cannot rely on RequireDocumentSession to retrieve the user. $user = $this->userSession->getUser(); @@ -127,7 +127,7 @@ public function save(int $version, string $autosaveContent, string $documentStat #[RequireDocumentSession] #[UserRateLimit(limit: 5, period: 120)] public function mention(string $mention): DataResponse { - if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocument()->getId(), $mention)) { + if ($this->getSession()->isGuest() && !$this->sessionService->isUserInDocument($this->getDocumentId(), $mention)) { return new DataResponse([], 403); } diff --git a/lib/Controller/TSessionAwareController.php b/lib/Controller/TSessionAwareController.php index 84afb600cb9..e3b9cf51aa6 100644 --- a/lib/Controller/TSessionAwareController.php +++ b/lib/Controller/TSessionAwareController.php @@ -16,7 +16,7 @@ trait TSessionAwareController { private ?Session $textSession = null; - private ?int $documentId = null; + private ?string $documentId = null; private ?Document $document = null; private ?IUser $user = null; @@ -24,7 +24,7 @@ public function setSession(?Session $session): void { $this->textSession = $session; } - public function setDocumentId(int $documentId): void { + public function setDocumentId(string $documentId): void { $this->documentId = $documentId; } @@ -50,7 +50,7 @@ public function getSession(): Session { /** * @throws InvalidSessionException */ - public function getDocumentId(): int { + public function getDocumentId(): string { if ($this->documentId === null) { throw new InvalidSessionException(); } diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index aadcb115a9e..54230100894 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -34,7 +34,7 @@ public function __construct( protected function run($argument): void { $this->logger->debug('Run cleanup job for text documents'); foreach ($this->documentService->getAllWithNoActiveSession() as $document) { - $this->attachmentService->cleanupAttachments($document->getId()); + $this->attachmentService->cleanupAttachments($document->id); } $this->logger->debug('Run cleanup job for text sessions'); diff --git a/lib/Db/Document.php b/lib/Db/Document.php index c550743c609..2e481cf36d4 100644 --- a/lib/Db/Document.php +++ b/lib/Db/Document.php @@ -1,5 +1,7 @@ addType('id', Types::STRING); $this->addType('currentVersion', Types::INTEGER); $this->addType('lastSavedVersion', Types::INTEGER); $this->addType('lastSavedVersionTime', Types::INTEGER); @@ -56,7 +61,7 @@ public function __construct() { public function jsonSerialize(): array { return [ - 'id' => $this->getId(), + 'id' => $this->id, 'lastSavedVersion' => $this->lastSavedVersion, 'lastSavedVersionTime' => $this->lastSavedVersionTime, 'baseVersionEtag' => $this->baseVersionEtag, diff --git a/lib/Db/DocumentMapper.php b/lib/Db/DocumentMapper.php index f94c0564faf..825958df6d9 100644 --- a/lib/Db/DocumentMapper.php +++ b/lib/Db/DocumentMapper.php @@ -24,7 +24,7 @@ public function __construct(IDBConnection $db) { * @return Document * @throws DoesNotExistException */ - public function find(int $documentId): Document { + public function find(string $documentId): Document { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); diff --git a/lib/Db/Session.php b/lib/Db/Session.php index 936dd7eb821..74b61fc1f0c 100644 --- a/lib/Db/Session.php +++ b/lib/Db/Session.php @@ -24,8 +24,8 @@ * @method void setLastAwarenessMessage(string $message) * @method int getLastContact() * @method void setLastContact(int $getTime) - * @method int getDocumentId() - * @method void setDocumentId(int $documentId) + * @method string getDocumentId() + * @method void setDocumentId(string $documentId) */ class Session extends Entity implements JsonSerializable { public $id; @@ -35,10 +35,10 @@ class Session extends Entity implements JsonSerializable { protected ?string $guestName = null; protected ?string $lastAwarenessMessage = ''; protected int $lastContact = 0; - protected int $documentId = 0; + protected string $documentId = ''; public function __construct() { - $this->addType('documentId', Types::INTEGER); + $this->addType('documentId', Types::STRING); $this->addType('lastContact', Types::INTEGER); } diff --git a/lib/Db/SessionMapper.php b/lib/Db/SessionMapper.php index cfe596447d0..9feb630ccb7 100644 --- a/lib/Db/SessionMapper.php +++ b/lib/Db/SessionMapper.php @@ -37,7 +37,7 @@ public function findAllDocuments(): array { * @return Session * @throws DoesNotExistException */ - public function find(int $documentId, int $sessionId, string $token): Session { + public function find(string $documentId, int $sessionId, string $token): Session { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') @@ -60,7 +60,7 @@ public function find(int $documentId, int $sessionId, string $token): Session { * * @psalm-return array */ - public function findAll(int $documentId): array { + public function findAll(string $documentId): array { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -69,7 +69,7 @@ public function findAll(int $documentId): array { return $this->findEntities($qb); } - public function countAll(int $documentId): int { + public function countAll(string $documentId): int { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -85,7 +85,7 @@ public function countAll(int $documentId): int { * * @psalm-return array */ - public function findAllActive(int $documentId): array { + public function findAllActive(string $documentId): array { $qb = $this->db->getQueryBuilder(); $qb->select('id', 'color', 'document_id', 'last_awareness_message', 'last_contact', 'user_id', 'guest_name') ->from($this->getTableName()) @@ -109,7 +109,7 @@ public function findAllInactive(): array { return $this->findEntities($qb); } - public function deleteInactiveWithoutSteps(?int $documentId = null): int { + public function deleteInactiveWithoutSteps(?string $documentId = null): int { $lastContact = time() - SessionService::SESSION_VALID_TIME; $inactiveSessionBuilder = $this->db->getQueryBuilder(); @@ -217,7 +217,7 @@ public function deleteOrphanedSteps(int $ageInSeconds): int { return $deletedCount; } - public function deleteByDocumentId(int $documentId): int { + public function deleteByDocumentId(string $documentId): int { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))); @@ -230,7 +230,7 @@ public function clearAll(): void { ->executeStatement(); } - public function isUserInDocument(int $documentId, string $userId): bool { + public function isUserInDocument(string $documentId, string $userId): bool { $qb = $this->db->getQueryBuilder(); $result = $qb->select('*') ->from($this->getTableName()) diff --git a/lib/Db/Step.php b/lib/Db/Step.php index 6dc009f2f46..c687974b28f 100644 --- a/lib/Db/Step.php +++ b/lib/Db/Step.php @@ -18,8 +18,8 @@ * @method setVersion(int $version): void * @method getSessionId(): int * @method setSessionId(int $sessionId): void - * @method getDocumentId(): int - * @method setDocumentId(int $documentId): void + * @method getDocumentId(): string + * @method setDocumentId(string $documentId): void * @method getTimestamp(): int * @method setTimestamp(int $timestam): void */ @@ -36,12 +36,12 @@ class Step extends Entity implements JsonSerializable { protected string $data = ''; protected int $version = 0; protected int $sessionId = 0; - protected int $documentId = 0; + protected string $documentId = ''; protected int $timestamp = 0; public function __construct() { $this->addType('version', Types::INTEGER); - $this->addType('documentId', Types::INTEGER); + $this->addType('documentId', Types::STRING); $this->addType('sessionId', Types::INTEGER); $this->addType('timestamp', Types::INTEGER); } diff --git a/lib/Db/StepMapper.php b/lib/Db/StepMapper.php index 26339b0912b..960428ac608 100644 --- a/lib/Db/StepMapper.php +++ b/lib/Db/StepMapper.php @@ -20,7 +20,7 @@ public function __construct(IDBConnection $db) { /** * @return Step[] */ - public function find(int $documentId, int $fromVersion): array { + public function find(string $documentId, int $fromVersion): array { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $qb->select('*') @@ -38,7 +38,7 @@ public function find(int $documentId, int $fromVersion): array { /** * @psalm-return ?positive-int */ - public function getLatestVersion(int $documentId): ?int { + public function getLatestVersion(string $documentId): ?int { /* @var $qb IQueryBuilder */ $qb = $this->db->getQueryBuilder(); $result = $qb->select('id') @@ -56,7 +56,7 @@ public function getLatestVersion(int $documentId): ?int { return $data['id']; } - public function getBeforeVersion(int $documentId, int $version, int $offset): int { + public function getBeforeVersion(string $documentId, int $version, int $offset): int { $qb = $this->db->getQueryBuilder(); $result = $qb->select('id') ->from($this->getTableName()) @@ -75,7 +75,7 @@ public function getBeforeVersion(int $documentId, int $version, int $offset): in return $data['id']; } - public function deleteAll(int $documentId): void { + public function deleteAll(string $documentId): void { $qb = $this->db->getQueryBuilder(); $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) @@ -89,7 +89,7 @@ public function clearAll(): void { } // not in use right now - public function deleteBeforeVersion(int $documentId, int $version): int { + public function deleteBeforeVersion(string $documentId, int $version): int { $qb = $this->db->getQueryBuilder(); return $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) @@ -97,7 +97,7 @@ public function deleteBeforeVersion(int $documentId, int $version): int { ->executeStatement(); } - public function deleteAfterVersion(int $documentId, int $version): int { + public function deleteAfterVersion(string $documentId, int $version): int { $qb = $this->db->getQueryBuilder(); return $qb->delete($this->getTableName()) ->where($qb->expr()->eq('document_id', $qb->createNamedParameter($documentId))) diff --git a/lib/Middleware/SessionMiddleware.php b/lib/Middleware/SessionMiddleware.php index 890d2d3f308..050cc9ede55 100644 --- a/lib/Middleware/SessionMiddleware.php +++ b/lib/Middleware/SessionMiddleware.php @@ -77,7 +77,7 @@ public function beforeController(Controller $controller, string $methodName): vo * @throws InvalidDocumentBaseVersionEtagException */ private function assertDocumentBaseVersionEtag(): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $baseVersionEtag = $this->request->getParam('baseVersionEtag'); $document = $this->documentService->getDocument($documentId); @@ -91,7 +91,7 @@ private function assertDocumentBaseVersionEtag(): void { * @throws AccountDisabledException */ private function assertDocumentSession(ISessionAwareController $controller): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $sessionId = (int)$this->request->getParam('sessionId'); $token = (string)$this->request->getParam('sessionToken'); @@ -124,7 +124,7 @@ private function assertDocumentSession(ISessionAwareController $controller): voi * @throws InvalidSessionException */ private function assertUserOrShareToken(ISessionAwareController $controller): void { - $documentId = (int)$this->request->getParam('documentId'); + $documentId = (string)$this->request->getParam('documentId'); $shareToken = (string)$this->request->getParam('shareToken'); $user = $this->userSession->getUser(); diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 8390d8f45dd..83ee665ced7 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -28,6 +28,7 @@ use OCP\IUser; use OCP\Share\IShare; use Psr\Log\LoggerInterface; +use RuntimeException; class ApiService { public function __construct( @@ -52,11 +53,15 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue try { $document = $this->documentService->getOrCreateDocument($document); + $documentId = $document->id; + if ($documentId === null) { + throw new RuntimeException('Persisted document must have an id.'); + } } catch (Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new DataResponse(['error' => 'Failed to create the document session'], Http::STATUS_INTERNAL_SERVER_ERROR); } - $documentData = $this->documentService->getDocumentData($document); + $documentData = $this->documentService->getDocumentData($documentId, $document); if ($baseVersionEtag !== null && $baseVersionEtag !== $document->getBaseVersionEtag()) { $error = $this->l10n->t('Editing session has expired. Please reload the page.'); @@ -64,8 +69,8 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue } $sessionInfo = $context->prepareSession($documentData); - $this->sessionService->removeInactiveSessionsWithoutSteps($document->id); - $session = $this->sessionService->initSession($document->id, $guestName); + $this->sessionService->removeInactiveSessionsWithoutSteps($documentId); + $session = $this->sessionService->initSession($documentId, $guestName); $displayName = $this->sessionService->getNameForSession($session); $newSession = new NewSessionData( @@ -80,7 +85,7 @@ public function create(IContext $context, ?string $baseVersionEtag, ?string $gue ); } - public function close(int $documentId, int $sessionId, string $sessionToken, IShare|IUser $auth): DataResponse { + public function close(string $documentId, int $sessionId, string $sessionToken, IShare|IUser $auth): DataResponse { $this->sessionService->closeSession($documentId, $sessionId, $sessionToken); $this->sessionService->removeInactiveSessionsWithoutSteps($documentId); $activeSessions = $this->sessionService->getActiveSessions($documentId); @@ -100,6 +105,9 @@ public function close(int $documentId, int $sessionId, string $sessionToken, ISh * @throws NotFoundException */ public function push(Session $session, Document $document, int $version, array $steps, string $awareness, ?int $recoveryAttempt, IShare|IUser $auth): DataResponse { + if ($document->id === null) { + throw new RuntimeException('Document needs to have an id to push.'); + } try { $session = $this->sessionService->updateSessionAwareness($session, $awareness); } catch (DoesNotExistException $e) { @@ -108,7 +116,7 @@ public function push(Session $session, Document $document, int $version, array $ } try { $result = $this->documentService->addStep($document, $session, $steps, $version, $recoveryAttempt, $auth); - $this->addToPushQueue($document, [$awareness, ...array_values($steps)]); + $this->addToPushQueue($document->id, [$awareness, ...array_values($steps)]); } catch (InvalidArgumentException $e) { return new DataResponse(['error' => $e->getMessage()], Http::STATUS_UNPROCESSABLE_ENTITY); } catch (DoesNotExistException) { @@ -120,12 +128,12 @@ public function push(Session $session, Document $document, int $version, array $ return new DataResponse($result); } - private function addToPushQueue(Document $document, array $steps): void { + private function addToPushQueue(string $documentId, array $steps): void { if ($this->queue === null || !$this->configService->isNotifyPushSyncEnabled()) { return; } - $sessions = $this->sessionService->getActiveSessions($document->id); + $sessions = $this->sessionService->getActiveSessions($documentId); $userIds = array_values(array_filter(array_unique( array_map(fn ($session): ?string => $session['userId'], $sessions) ))); @@ -134,7 +142,7 @@ private function addToPushQueue(Document $document, array $steps): void { 'user' => $userId, 'message' => 'text_steps', 'body' => [ - 'documentId' => $document->getId(), + 'documentId' => $documentId, 'steps' => array_values(array_filter($steps)), ], ]); @@ -142,6 +150,9 @@ private function addToPushQueue(Document $document, array $steps): void { } public function sync(Document $document, IShare|IUser $auth, int $version = 0): DataResponse { + if ($document->id === null) { + throw new RuntimeException('Document needs to have an id to sync.'); + } $result = []; try { $result = [ diff --git a/lib/Service/AttachmentService.php b/lib/Service/AttachmentService.php index ad7685eddcb..9c7da305556 100755 --- a/lib/Service/AttachmentService.php +++ b/lib/Service/AttachmentService.php @@ -62,7 +62,7 @@ public function __construct( * @throws NotFoundException * @throws NotPermittedException */ - public function getImageFile(int $documentId, string $imageFileName, IShare|IUser $auth, bool $preferRawImage): File|ISimpleFile|null { + public function getImageFile(string $documentId, string $imageFileName, IShare|IUser $auth, bool $preferRawImage): File|ISimpleFile|null { $textFile = $this->getTextFile($documentId, $auth); return $this->getImageFileContent($imageFileName, $textFile, $preferRawImage); } @@ -102,7 +102,7 @@ private function getImageFileContent(string $imageFileName, File $textFile, bool * @throws NotPermittedException * @throws NoUserException */ - public function getMediaFile(int $documentId, string $mediaFileName, IShare|IUser $auth): ?File { + public function getMediaFile(string $documentId, string $mediaFileName, IShare|IUser $auth): ?File { $textFile = $this->getTextFile($documentId, $auth); return $this->getMediaFullFile($mediaFileName, $textFile); } @@ -128,7 +128,7 @@ private function getMediaFullFile(string $mediaFileName, File $textFile): ?File * @throws InvalidPathException * @throws NoUserException */ - public function getMediaFilePreview(int $documentId, string $mediaFileName, IShare|IUser $auth): ?array { + public function getMediaFilePreview(string $documentId, string $mediaFileName, IShare|IUser $auth): ?array { $textFile = $this->getTextFile($documentId, $auth); return $this->getMediaFilePreviewFile($mediaFileName, $textFile); } @@ -170,7 +170,7 @@ private function getMediaFilePreviewFile(string $mediaFileName, File $textFile): * @throws NotFoundException * @throws NotPermittedException */ - public function getAttachmentList(int $documentId, IShare|IUser $auth, ?Session $session = null): array { + public function getAttachmentList(string $documentId, IShare|IUser $auth, ?Session $session = null): array { $textFile = $this->getTextFile($documentId, $auth); try { @@ -242,7 +242,7 @@ public function getAttachmentList(int $documentId, IShare|IUser $auth, ?Session * @throws InvalidPathException * @throws NoUserException */ - public function uploadAttachment(int $documentId, string $newFileName, $newFileResource, IShare|IUser $auth): array { + public function uploadAttachment(string $documentId, string $newFileName, $newFileResource, IShare|IUser $auth): array { if ($auth instanceof IShare && $auth->getPassword() !== null) { $key = PublicAuth::DAV_AUTHENTICATED; @@ -282,7 +282,7 @@ public function uploadAttachment(int $documentId, string $newFileName, $newFileR * @throws InvalidPathException * @throws NoUserException */ - public function insertAttachmentFile(int $documentId, string $path, IUser $user): array { + public function insertAttachmentFile(string $documentId, string $path, IUser $user): array { $textFile = $this->getTextFile($documentId, $user); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); @@ -309,7 +309,7 @@ public function insertAttachmentFile(int $documentId, string $path, IUser $user) * @throws InvalidPathException * @throws NoUserException */ - public function createAttachmentFile(int $documentId, string $newFileName, IUser $user): array { + public function createAttachmentFile(string $documentId, string $newFileName, IUser $user): array { $textFile = $this->getTextFile($documentId, $user); if (!$textFile->isUpdateable()) { throw new NotPermittedException('No write permissions'); @@ -420,7 +420,7 @@ private function isDownloadDisabled(File $file): bool { * @throws NotFoundException * @throws NotPermittedException */ - private function getTextFile(int $documentId, IShare|IUser $auth): File { + private function getTextFile(string $documentId, IShare|IUser $auth): File { try { $document = $this->documentMapper->find($documentId); } catch (DoesNotExistException $e) { diff --git a/lib/Service/DocumentService.php b/lib/Service/DocumentService.php index f3114fe9c7e..995ff1731d2 100644 --- a/lib/Service/DocumentService.php +++ b/lib/Service/DocumentService.php @@ -41,6 +41,7 @@ use OCP\Lock\LockedException; use OCP\Share\IShare; use Psr\Log\LoggerInterface; +use RuntimeException; use function json_encode; class DocumentService { @@ -80,7 +81,7 @@ public function __construct( } } - public function getDocument(int $id): ?Document { + public function getDocument(string $id): ?Document { try { return $this->documentMapper->find($id); } catch (DoesNotExistException|NotFoundException) { @@ -111,6 +112,7 @@ public function getOrCreateDocument(Document $document): Document { $this->logger->info('Create new document of ' . $document->toString()); try { + $document->generateId(); /** @var Document $document */ $document = $this->documentMapper->insert($document); $this->cache->set('document-version-' . $document->id, 0); @@ -127,12 +129,12 @@ public function getOrCreateDocument(Document $document): Document { return $document; } - public function getDocumentData(Document $document): DocumentData { + public function getDocumentData(string $documentId, Document $document): DocumentData { $documentState = null; if ($document->getLastSavedVersion() > 0) { $this->logger->debug('Loading saved document state for ' . $document->toString()); try { - $stateFile = $this->getStateFile($document->id); + $stateFile = $this->getStateFile($documentId); $documentState = $stateFile->getContent(); } catch (NotFoundException) { // If we have no state file we need to load the content from the file @@ -150,11 +152,11 @@ public function getDocumentData(Document $document): DocumentData { } /** - * @param int $documentId + * @param string $documentId * @return ISimpleFile * @throws NotFoundException */ - public function getStateFile(int $documentId): ISimpleFile { + public function getStateFile(string $documentId): ISimpleFile { $filename = $documentId . '.yjs'; if (!$this->ensureDocumentsFolder()) { throw new NotFoundException('No app data folder present for text documents'); @@ -190,21 +192,21 @@ public function updateDocument(Document $document): void { } /** - * @param int $documentId + * @param string $documentId * * @return ISimpleFile * @throws NotPermittedException */ - public function createStateFile(int $documentId): ISimpleFile { + public function createStateFile(string $documentId): ISimpleFile { $filename = $documentId . '.yjs'; return $this->appData->getFolder('documents')->newFile($filename); } /** - * @param int $documentId + * @param string $documentId * @param string $content */ - public function writeDocumentState(int $documentId, string $content): void { + public function writeDocumentState(string $documentId, string $content): void { try { $documentStateFile = $this->getStateFile($documentId); } catch (NotFoundException) { @@ -245,7 +247,7 @@ public function addStep(Document $document, Session $session, array $steps, int $id = $document->getContextId(); $context = $this->contextManager->getContext($type, $id, $auth); if (!$context->isReadOnly()) { - $this->insertSteps($document, $session, $stepsToInsert); + $this->insertSteps($documentId, $session, $stepsToInsert); } } @@ -289,7 +291,7 @@ public function addStep(Document $document, Session $session, array $steps, int } /** - * @param Document $document + * @param string $documentId * @param Session $session * @param Step[] $steps * @@ -298,34 +300,34 @@ public function addStep(Document $document, Session $session, array $steps, int * * @psalm-param non-empty-list $steps */ - private function insertSteps(Document $document, Session $session, array $steps): void { + private function insertSteps(string $documentId, Session $session, array $steps): void { $stepsVersion = null; try { $stepsJson = json_encode($steps, JSON_THROW_ON_ERROR); - $stepsVersion = $this->stepMapper->getLatestVersion($document->getId()); + $stepsVersion = $this->stepMapper->getLatestVersion($documentId); $step = new Step(); $step->setData($stepsJson); $step->setSessionId($session->getId()); - $step->setDocumentId($document->getId()); + $step->setDocumentId($documentId); $step->setVersion(Step::VERSION_STORED_IN_ID); $step->setTimestamp(time()); $step = $this->stepMapper->insert($step); $newVersion = $step->getId(); - $this->logger->debug('Adding steps to ' . $document->getId() . ": bumping version from $stepsVersion to $newVersion"); - $this->cache->set('document-version-' . $document->getId(), $newVersion); + $this->logger->debug('Adding steps to ' . $documentId . ": bumping version from $stepsVersion to $newVersion"); + $this->cache->set('document-version-' . $documentId, $newVersion); // TODO write steps to cache for quicker reading } catch (\Throwable $e) { if ($stepsVersion !== null) { $this->logger->error('This should never happen. An error occurred when storing the version, trying to recover the last stable one', ['exception' => $e]); - $this->cache->set('document-version-' . $document->getId(), $stepsVersion); - $this->stepMapper->deleteAfterVersion($document->getId(), $stepsVersion); + $this->cache->set('document-version-' . $documentId, $stepsVersion); + $this->stepMapper->deleteAfterVersion($documentId, $stepsVersion); } throw $e; } } /** @return Step[] */ - public function getSteps(int $documentId, int $lastVersion): array { + public function getSteps(string $documentId, int $lastVersion): array { if ($lastVersion === $this->cache->get('document-version-' . $documentId)) { return []; } @@ -349,6 +351,9 @@ public static function computeCheckSum(string $content): string { * @throws Exception */ public function autosave(Document $document, IContext $context, int $version, string $autoSaveDocument, string $documentState, bool $force = false, bool $manualSave = false): Document { + if ($document->id === null) { + throw new RuntimeException('Document needs an id to autosave'); + } if ($context->isReadOnly()) { throw new NotPermittedException('Read-only permission cannot save document changes. Please reload the page.'); } @@ -437,13 +442,15 @@ public function resetDocument(string $contextType, int $contextId, bool $force = $contextString = $contextType . '(' . $contextId . ')'; $document = $this->documentMapper->load($contextType, $contextId); - if (!$document) { + if (!$document || $document->id === null) { // no document found for the file in question - so nothing to reset. $this->logger->info('did not find document - document not reset.' . $contextString); return; } - if (!$force && $this->hasUnsavedChanges($document)) { + $stepsVersion = $this->stepMapper->getLatestVersion($document->id) ?: 0; + $docVersion = $document->getLastSavedVersion(); + if (!$force && $stepsVersion !== $docVersion) { $this->logger->debug('Did not reset document with unsaved changes for ' . $contextString); throw new DocumentHasUnsavedChangesException('Did not reset document, as it has unsaved changes'); } @@ -469,12 +476,6 @@ public function getAllWithNoActiveSession(): \Generator { return $this->documentMapper->findAllWithNoActiveSessions(); } - public function hasUnsavedChanges(Document $document): bool { - $stepsVersion = $this->stepMapper->getLatestVersion($document->getId()) ?: 0; - $docVersion = $document->getLastSavedVersion(); - return $stepsVersion !== $docVersion; - } - private function ensureDocumentsFolder(): bool { try { $this->appData->getFolder('documents'); diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index 6cf2f872ff9..6ebbb984f74 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -54,7 +54,7 @@ public function __construct( $this->cache = $cacheFactory->createDistributed('text_sessions'); } - public function initSession(int $documentId, ?string $guestName = null): Session { + public function initSession(string $documentId, ?string $guestName = null): Session { $session = new Session(); $session->setDocumentId($documentId); $session->setUserId($this->userId); @@ -71,7 +71,7 @@ public function initSession(int $documentId, ?string $guestName = null): Session return $session; } - public function closeSession(int $documentId, int $sessionId, string $token): void { + public function closeSession(string $documentId, int $sessionId, string $token): void { try { $session = $this->sessionMapper->find($documentId, $sessionId, $token); $this->cache->remove($token); @@ -80,7 +80,7 @@ public function closeSession(int $documentId, int $sessionId, string $token): vo } } - public function getAllSessions(int $documentId): array { + public function getAllSessions(string $documentId): array { $sessions = $this->sessionMapper->findAll($documentId); return array_map(function (Session $session) { $result = $session->jsonSerialize(); @@ -92,11 +92,11 @@ public function getAllSessions(int $documentId): array { }, $sessions); } - public function countAllSessions(int $documentId): int { + public function countAllSessions(string $documentId): int { return $this->sessionMapper->countAll($documentId); } - public function getActiveSessions(int $documentId): array { + public function getActiveSessions(string $documentId): array { $sessions = $this->sessionMapper->findAllActive($documentId); return array_map(function (Session $session) { $result = $session->jsonSerialize(); @@ -122,7 +122,7 @@ public function findAllInactive(): array { return $this->sessionMapper->findAllInactive(); } - public function removeInactiveSessionsWithoutSteps(?int $documentId = null): int { + public function removeInactiveSessionsWithoutSteps(?string $documentId = null): int { // No need to clear the cache here as we already set a TTL return $this->sessionMapper->deleteInactiveWithoutSteps($documentId); } @@ -135,7 +135,7 @@ public function removeOrphanedSteps(int $ageInSeconds = 604800): int { return $this->sessionMapper->deleteOrphanedSteps($ageInSeconds); } - public function getSession(int $documentId, int $sessionId, string $token): ?Session { + public function getSession(string $documentId, int $sessionId, string $token): ?Session { if ($this->session !== null) { return $this->session; } @@ -162,7 +162,7 @@ public function getSession(int $documentId, int $sessionId, string $token): ?Ses return $this->session; } - public function getValidSession(int $documentId, int $sessionId, string $token): ?Session { + public function getValidSession(string $documentId, int $sessionId, string $token): ?Session { $session = $this->getSession($documentId, $sessionId, $token); if ($session === null) { return null; @@ -241,7 +241,7 @@ private function generateRandomString(int $length = 64): string { return $randomizer->getBytesFromString(ISecureRandom::CHAR_ALPHANUMERIC . '+/', $length); } - public function isUserInDocument(int $documentId, string $mention): bool { + public function isUserInDocument(string $documentId, string $mention): bool { return $this->sessionMapper->isUserInDocument($documentId, $mention); } } diff --git a/tests/unit/Db/SessionMapperTest.php b/tests/unit/Db/SessionMapperTest.php index b0da154b7a1..452e7b3ac9d 100644 --- a/tests/unit/Db/SessionMapperTest.php +++ b/tests/unit/Db/SessionMapperTest.php @@ -145,13 +145,15 @@ public function testDeleteOrphanedSteps() { $eightDaysAgo = time() - (8 * 24 * 60 * 60); // Create document - $document = $this->documentMapper->insert(Document::fromParams([ + $document = Document::fromParams([ 'contextId' => 123, 'contextType' => 'file', 'currentVersion' => 0, 'lastSavedVersion' => 100, 'lastSavedVersionTime' => time() - ])); + ]); + $document->generateId(); + $document = $this->documentMapper->insert($document); // Create Orphaned step without document (delete) $this->stepMapper->insert(Step::fromParams([ @@ -165,7 +167,7 @@ public function testDeleteOrphanedSteps() { $this->stepMapper->insert(Step::fromParams([ 'id' => 1, 'sessionId' => 99999, - 'documentId' => $document->getId(), + 'documentId' => $document->id, 'data' => 'ORPHANED_OLD_VERSION', 'timestamp' => $eightDaysAgo, 'version' => 1 @@ -175,7 +177,7 @@ public function testDeleteOrphanedSteps() { $this->stepMapper->insert(Step::fromParams([ 'id' => 100, 'sessionId' => 99999, - 'documentId' => $document->getId(), + 'documentId' => $document->id, 'data' => 'ORPHANED_CURRENT_VERSION', 'version' => 2 ])); @@ -184,14 +186,14 @@ public function testDeleteOrphanedSteps() { $this->stepMapper->insert(Step::fromParams([ 'id' => 101, 'sessionId' => 99999, - 'documentId' => $document->getId(), + 'documentId' => $document->id, 'data' => 'ORPHANED_NEW_VERSION', 'timestamp' => $eightDaysAgo, 'version' => 3 ])); // Verify steps for document 1 and 99999 - self::assertCount(3, $this->stepMapper->find(1, 0)); + self::assertCount(3, $this->stepMapper->find($document->id, 0)); self::assertCount(1, $this->stepMapper->find(99999, 0)); // Delete orphaned steps older than 7 days diff --git a/tests/unit/Middleware/SessionMiddlewareTest.php b/tests/unit/Middleware/SessionMiddlewareTest.php index a57a18a08be..36aedc3087e 100644 --- a/tests/unit/Middleware/SessionMiddlewareTest.php +++ b/tests/unit/Middleware/SessionMiddlewareTest.php @@ -28,6 +28,7 @@ class SessionMiddlewareTest extends TestCase { private IUserSession $userSession; private IUserManager $userManager; private FileService $fileService; + private string $documentId; protected function setUp(): void { parent::setUp(); @@ -40,10 +41,11 @@ protected function setUp(): void { $this->fileService = $this->createMock(FileService::class); $document = new Document(); - $document->setId(111); $document->setContextId(999); $document->setContextType('file'); - $this->documentService->method('getDocument')->with(111)->willReturn($document); + $document->generateId(); + $this->documentId = $document->id; + $this->documentService->method('getDocument')->with($document->id)->willReturn($document); $this->middleware = new SessionMiddleware( $this->request, @@ -191,7 +193,7 @@ public function testAfterExceptionMapsAccountDisabledToForbidden(): void { private function invokeAssertDocumentSession(ISessionAwareController $controller, ?string $shareToken = null): void { $this->request->method('getParam')->willReturnMap([ - ['documentId', null, 111], + ['documentId', null, $this->documentId], ['sessionId', null, 1], ['sessionToken', null, 'sessionToken'], ['token', null, $shareToken], @@ -202,7 +204,7 @@ private function invokeAssertDocumentSession(ISessionAwareController $controller private function invokeMiddleware(?string $token, ?string $userName = null, ?ISessionAwareController $controller = null): void { $this->request->method('getParam')->willReturnMap([ - ['documentId', null, 111], + ['documentId', null, $this->documentId], ['shareToken', null, $token], ]); diff --git a/tests/unit/Service/ApiServiceTest.php b/tests/unit/Service/ApiServiceTest.php index e386d0a4424..51b63663f03 100644 --- a/tests/unit/Service/ApiServiceTest.php +++ b/tests/unit/Service/ApiServiceTest.php @@ -50,7 +50,7 @@ public function setUp(): void { public function testCreateNewSession() { $document = new Document(); - $document->setId(123); + $document->generateId(); $context = $this->createMock(IContext::class); $documentData = new DocumentData(document: $document, documentState: 'documentState'); $sessionInfo = new SessionInfo(content: 'content', readOnly: false, lock: null, hasOwner: true); @@ -79,6 +79,7 @@ public function testSaveWithNotPermittedException() { $session->setDocumentId(123); $document = new Document(); + $document->generateId(); $user = $this->createStub(IUser::class); $this->documentService->method('autosave')->willThrowException(new \OCP\Files\NotPermittedException()); diff --git a/tests/unit/Service/DocumentServiceTest.php b/tests/unit/Service/DocumentServiceTest.php index 2c631afaf24..e06b2d0196e 100644 --- a/tests/unit/Service/DocumentServiceTest.php +++ b/tests/unit/Service/DocumentServiceTest.php @@ -55,10 +55,10 @@ public function setUp(): void { private function createDocument(string $etag, int $mtime, string $content): Document { $document = new Document(); - $document->setId(123); $document->setLastSavedVersionEtag($etag); $document->setLastSavedVersionTime($mtime); $document->setChecksum(DocumentService::computeCheckSum($content)); + $document->generateId(); return $document; } @@ -66,7 +66,7 @@ public function testNoAutosavingWhileSaveIsUnderWay(): void { $document = $this->createDocument('etag1', 1000, 'new content'); $context = $this->createMock(FileContext::class); $this->cache->method('get') - ->with('document-save-lock-123') + ->with('document-save-lock-' . $document->id) ->willReturn(true); $this->documentMapper->expects(self::never())->method('update'); From 0b32f900a5d865761b9b55c7220571e6fdce7c29 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 9 Sep 2026 09:32:18 +0200 Subject: [PATCH 03/12] fix(cron): cleanup attachments by file id not document id Signed-off-by: Max --- lib/Cron/Cleanup.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Cron/Cleanup.php b/lib/Cron/Cleanup.php index 54230100894..bffe1dc37d6 100644 --- a/lib/Cron/Cleanup.php +++ b/lib/Cron/Cleanup.php @@ -34,7 +34,9 @@ public function __construct( protected function run($argument): void { $this->logger->debug('Run cleanup job for text documents'); foreach ($this->documentService->getAllWithNoActiveSession() as $document) { - $this->attachmentService->cleanupAttachments($document->id); + if ($document->getContextType() === 'file') { + $this->attachmentService->cleanupAttachments($document->getContextId()); + } } $this->logger->debug('Run cleanup job for text sessions'); From c3263c3ec12f7d20267fd934174378bab6fdad12 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 9 Sep 2026 12:45:31 +0200 Subject: [PATCH 04/12] fix(db): remove autoincrement from documents migration It does not work on all database types. Therefore we will use snowflake ids instead. Signed-off-by: Max --- lib/Migration/Version090000Date20260817110024.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/Migration/Version090000Date20260817110024.php b/lib/Migration/Version090000Date20260817110024.php index 627d673b141..ea3de1160cb 100644 --- a/lib/Migration/Version090000Date20260817110024.php +++ b/lib/Migration/Version090000Date20260817110024.php @@ -40,12 +40,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt ]); } - $column = $table->getColumn('id'); - if (!$column->getAutoincrement()) { - $table->modifyColumn('id', [ - 'autoincrement' => true, - ]); - } return $schema; } } From 42368e20aeab9f6199c1da61fac4cc1e1e284c80 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 9 Sep 2026 12:50:07 +0200 Subject: [PATCH 05/12] fix(occ): ResetDocument with file id from context Signed-off-by: Max --- lib/Command/ResetDocument.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Command/ResetDocument.php b/lib/Command/ResetDocument.php index 91d23498be2..a04c095aa90 100644 --- a/lib/Command/ResetDocument.php +++ b/lib/Command/ResetDocument.php @@ -75,7 +75,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($all) { $fileIds = []; foreach ($this->documentService->getAll() as $document) { - $fileIds[] = $document->id; + if ($document->getContextType() === 'file') { + $fileIds[] = $document->getContextId(); + } } } else { $fileIds = [$fileId]; From b6868bd2d60db0e935b80a2e36f7c4328c17dbf1 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 9 Sep 2026 13:20:59 +0200 Subject: [PATCH 06/12] chore(refactor): move types used everywhere into types directory Signed-off-by: Max --- src/EditorFactory.ts | 2 +- src/apis/attach.ts | 2 +- src/apis/connect.ts | 5 +- src/apis/mention.ts | 2 +- src/apis/save.ts | 4 +- src/apis/sync.ts | 6 +- src/components/CollaborativeEditor.vue | 3 +- .../Editor/DocumentStatus/SyncStatus.vue | 2 +- src/components/Editor/SessionStatus.vue | 2 +- src/composables/useConnection.ts | 19 ++---- src/composables/useEditorMethods.ts | 7 +- src/composables/useFileProps.ts | 2 +- src/composables/useIndexedDbProvider.ts | 2 +- src/composables/useSaveService.ts | 5 +- src/composables/useSessions.ts | 2 +- src/composables/useSyncService.ts | 3 +- src/createEditor.ts | 2 +- src/extensions/Autofocus.ts | 2 +- src/extensions/RichText.ts | 2 +- src/helpers/steps.ts | 3 +- src/helpers/yjs.ts | 2 +- src/services/PollingBackend.ts | 9 ++- src/services/SaveService.ts | 6 +- src/services/SyncService.ts | 67 ++----------------- src/services/WebSocketPolyfill.ts | 3 +- src/tests/helpers/updateFromContent.spec.ts | 2 +- src/types/Connection.ts | 13 ++++ src/types/Context.ts | 9 +++ src/types/Document.ts | 7 ++ src/types/ErrorType.ts | 25 +++++++ src/types/Session.ts | 25 +++++++ src/types/Step.ts | 14 ++++ src/views/CollaborativeEditorApp.vue | 2 +- 33 files changed, 152 insertions(+), 109 deletions(-) create mode 100644 src/types/Connection.ts create mode 100644 src/types/Context.ts create mode 100644 src/types/Document.ts create mode 100644 src/types/ErrorType.ts create mode 100644 src/types/Session.ts create mode 100644 src/types/Step.ts diff --git a/src/EditorFactory.ts b/src/EditorFactory.ts index ab262591459..bfe5e119faf 100644 --- a/src/EditorFactory.ts +++ b/src/EditorFactory.ts @@ -5,7 +5,7 @@ import type { Extension } from '@tiptap/core' import type { Node } from '@tiptap/pm/model' -import type { Connection } from './composables/useConnection.ts' +import type { Connection } from './types/Connection.ts' import { Editor } from '@tiptap/core' import hljs from 'highlight.js/lib/core' diff --git a/src/apis/attach.ts b/src/apis/attach.ts index 9dcbab90903..1ecbf20a010 100644 --- a/src/apis/attach.ts +++ b/src/apis/attach.ts @@ -4,7 +4,7 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/connect.ts b/src/apis/connect.ts index 9d21985c525..3b1a306f9fb 100644 --- a/src/apis/connect.ts +++ b/src/apis/connect.ts @@ -3,8 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Connection } from '../composables/useConnection.ts' -import type { Document, GuestSession, Session } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { GuestSession, Session } from '../types/Session.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/mention.ts b/src/apis/mention.ts index de562b9c469..a742a5fd5e3 100644 --- a/src/apis/mention.ts +++ b/src/apis/mention.ts @@ -4,7 +4,7 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/apis/save.ts b/src/apis/save.ts index 554861736b3..fcdcf78d126 100644 --- a/src/apis/save.ts +++ b/src/apis/save.ts @@ -4,8 +4,8 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' -import type { Document } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { getRequestToken } from '@nextcloud/auth' import axios from '@nextcloud/axios' diff --git a/src/apis/sync.ts b/src/apis/sync.ts index 13bbedc0c5a..d17410ed3e3 100644 --- a/src/apis/sync.ts +++ b/src/apis/sync.ts @@ -4,8 +4,10 @@ */ import type { ShallowRef } from 'vue' -import type { Connection } from '../composables/useConnection.ts' -import type { Document, Session, Step } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' diff --git a/src/components/CollaborativeEditor.vue b/src/components/CollaborativeEditor.vue index da54b53e49a..51334d566b1 100644 --- a/src/components/CollaborativeEditor.vue +++ b/src/components/CollaborativeEditor.vue @@ -115,8 +115,9 @@ import { CollaborationCaret } from '../extensions/index.js' import { exposeForDebugging, removeFromDebugging } from '../helpers/debug.ts' import { logger } from '../helpers/logger.ts' import { setInitialYjsState } from '../helpers/setInitialYjsState.ts' -import { ERROR_TYPE, IDLE_TIMEOUT } from '../services/SyncService.ts' +import { IDLE_TIMEOUT } from '../services/SyncService.ts' import { fetchNode } from '../services/WebdavClient.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' import { createPlainEditor, createRichEditor, diff --git a/src/components/Editor/DocumentStatus/SyncStatus.vue b/src/components/Editor/DocumentStatus/SyncStatus.vue index 7feaa069c82..083a6fc37ec 100644 --- a/src/components/Editor/DocumentStatus/SyncStatus.vue +++ b/src/components/Editor/DocumentStatus/SyncStatus.vue @@ -24,7 +24,7 @@ import { t } from '@nextcloud/l10n' import NcButton from '@nextcloud/vue/components/NcButton' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import { ERROR_TYPE } from '../../../services/SyncService.ts' +import { ERROR_TYPE } from '../../../types/ErrorType.ts' export default { name: 'SyncStatus', diff --git a/src/components/Editor/SessionStatus.vue b/src/components/Editor/SessionStatus.vue index 82fcbf242cb..15869675f67 100644 --- a/src/components/Editor/SessionStatus.vue +++ b/src/components/Editor/SessionStatus.vue @@ -41,7 +41,7 @@ import OfflineState from './OfflineState.vue' import { useNetworkState } from '../../composables/useNetworkState.ts' import { useSaveService } from '../../composables/useSaveService.ts' import refreshMoment from '../../mixins/refreshMoment.js' -import { ERROR_TYPE } from '../../services/SyncService.ts' +import { ERROR_TYPE } from '../../types/ErrorType.ts' import { useIsMobileMixin } from '../Editor.provider.ts' export default { diff --git a/src/composables/useConnection.ts b/src/composables/useConnection.ts index 925d2526a1e..81223ea8383 100644 --- a/src/composables/useConnection.ts +++ b/src/composables/useConnection.ts @@ -5,25 +5,14 @@ import type { InjectionKey, ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' -import type { Document, Session } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Context } from '../types/Context.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' import { inject, provide, shallowRef } from 'vue' import * as api from '../apis/connect.ts' -export interface Context { - type: string - id: number -} - -export interface Connection { - documentId: number - sessionId: number - sessionToken: string - baseVersionEtag: string - filePath: string - shareToken?: string -} - export interface InitialData { document: Document session: Session & { token: string } diff --git a/src/composables/useEditorMethods.ts b/src/composables/useEditorMethods.ts index 7dfab0f7f7a..7cab77144d5 100644 --- a/src/composables/useEditorMethods.ts +++ b/src/composables/useEditorMethods.ts @@ -5,13 +5,18 @@ import type { Editor } from '@tiptap/core' import type { AwarenessUser } from '../extensions/CollaborationCaret.ts' -import type { Session } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' import escapeHtml from 'escape-html' import Markdown from '../extensions/Markdown.js' import markdownit from '../markdownit/index.js' import { isUser } from '../services/SyncService.ts' +/** + * + * @param content + * @param markdown + */ export function renderEditorContent(content: string, markdown: boolean) { return markdown ? markdownit.render(content) + '

' diff --git a/src/composables/useFileProps.ts b/src/composables/useFileProps.ts index e6647a5528f..7f729742f47 100644 --- a/src/composables/useFileProps.ts +++ b/src/composables/useFileProps.ts @@ -4,7 +4,7 @@ */ import type { InjectionKey } from 'vue' -import type { Context } from './useConnection.ts' +import type { Context } from '../types/Context.ts' import { inject, provide } from 'vue' diff --git a/src/composables/useIndexedDbProvider.ts b/src/composables/useIndexedDbProvider.ts index 81dbc046e32..e178192f396 100644 --- a/src/composables/useIndexedDbProvider.ts +++ b/src/composables/useIndexedDbProvider.ts @@ -4,7 +4,7 @@ */ import type { Doc } from 'yjs' -import type { Context } from './useConnection.ts' +import type { Context } from '../types/Context.ts' import { readonly, ref } from 'vue' import { IndexeddbPersistence } from 'y-indexeddb' diff --git a/src/composables/useSaveService.ts b/src/composables/useSaveService.ts index 7d1b3bfa9d0..a2b02b8eb80 100644 --- a/src/composables/useSaveService.ts +++ b/src/composables/useSaveService.ts @@ -6,8 +6,9 @@ import type { InjectionKey, ShallowRef } from 'vue' import type { Doc } from 'yjs' import type { SaveData } from '../apis/save.ts' -import type { Document, SyncService } from '../services/SyncService.ts' -import type { Connection } from './useConnection.ts' +import type { SyncService } from '../services/SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { computed, inject, onUnmounted, provide, ref, watch } from 'vue' import { getDocumentState } from '../helpers/yjs.ts' diff --git a/src/composables/useSessions.ts b/src/composables/useSessions.ts index 3ea68e85e06..1db4f8035b2 100644 --- a/src/composables/useSessions.ts +++ b/src/composables/useSessions.ts @@ -6,7 +6,7 @@ import type { ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' import type { SyncService } from '../services/SyncService.ts' -import type { Session } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' import { computed, diff --git a/src/composables/useSyncService.ts b/src/composables/useSyncService.ts index 3a612f08c46..1743f1cdbb1 100644 --- a/src/composables/useSyncService.ts +++ b/src/composables/useSyncService.ts @@ -4,7 +4,8 @@ */ import type { InjectionKey, ShallowRef } from 'vue' -import type { Connection, InitialData } from './useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { InitialData } from './useConnection.ts' import { inject, provide } from 'vue' import { SyncService } from '../services/SyncService.ts' diff --git a/src/createEditor.ts b/src/createEditor.ts index c48db34373d..d966b07d6a3 100644 --- a/src/createEditor.ts +++ b/src/createEditor.ts @@ -5,8 +5,8 @@ import type { EventHandler } from '@nextcloud/event-bus' import type { App } from 'vue' -import type { Context } from './composables/useConnection.ts' import type { TextEditorEmbed } from './TextEditorEmbed.ts' +import type { Context } from './types/Context.ts' import { createApp, reactive, shallowRef } from 'vue' import { diff --git a/src/extensions/Autofocus.ts b/src/extensions/Autofocus.ts index 15dbdb30cdc..1ccb88c6525 100644 --- a/src/extensions/Autofocus.ts +++ b/src/extensions/Autofocus.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Context } from '../composables/useConnection.ts' +import type { Context } from '../types/Context.ts' import { Extension } from '@tiptap/core' diff --git a/src/extensions/RichText.ts b/src/extensions/RichText.ts index e1162e122ad..ce8dd5b37ba 100644 --- a/src/extensions/RichText.ts +++ b/src/extensions/RichText.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import type { AnyExtension, Extensions } from '@tiptap/core' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' import { t } from '@nextcloud/l10n' import { Extension } from '@tiptap/core' diff --git a/src/helpers/steps.ts b/src/helpers/steps.ts index 12c1a6bf3e0..7f563571533 100644 --- a/src/helpers/steps.ts +++ b/src/helpers/steps.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Session, Step } from '../services/SyncService.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import { COLLABORATOR_DISCONNECT_TIME } from '../services/SyncService.ts' diff --git a/src/helpers/yjs.ts b/src/helpers/yjs.ts index a6d926aaf96..a084d8c506f 100644 --- a/src/helpers/yjs.ts +++ b/src/helpers/yjs.ts @@ -4,7 +4,7 @@ */ import type { OpenData } from '../apis/connect.ts' -import type { Step } from '../services/SyncService.ts' +import type { Step } from '../types/Step.ts' import * as decoding from 'lib0/decoding.js' import * as encoding from 'lib0/encoding.js' diff --git a/src/services/PollingBackend.ts b/src/services/PollingBackend.ts index ff6fe94e826..271bc145681 100644 --- a/src/services/PollingBackend.ts +++ b/src/services/PollingBackend.ts @@ -4,14 +4,17 @@ */ import type { Emitter } from 'mitt' import type { OpenData } from '../apis/connect.ts' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { Session } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import type { EventTypes } from './NotifyService.ts' -import type { Document, Session, Step, SyncService } from './SyncService.js' +import type { SyncService } from './SyncService.js' import { sync } from '../apis/sync.ts' import { logger } from '../helpers/logger.js' +import { ERROR_TYPE } from '../types/ErrorType.ts' import getNotifyBus from './NotifyService.ts' -import { ERROR_TYPE } from './SyncService.js' /** * Minimum inverval to refetch the document changes in ms. diff --git a/src/services/SaveService.ts b/src/services/SaveService.ts index 0992ea12d71..fb070c69197 100644 --- a/src/services/SaveService.ts +++ b/src/services/SaveService.ts @@ -5,15 +5,15 @@ import type { Ref, ShallowRef } from 'vue' import type { SaveData } from '../apis/save.ts' -import type { Connection } from '../composables/useConnection.ts' -import type { Document } from './SyncService.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' import { showError } from '@nextcloud/dialogs' import debounce from 'debounce' import mitt from 'mitt' import { save, saveViaSendBeacon } from '../apis/save.ts' import { logger } from '../helpers/logger.js' -import { ERROR_TYPE } from './SyncService.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' // Time constants in seconds: // Only autosave after 1 second typing breaks diff --git a/src/services/SyncService.ts b/src/services/SyncService.ts index 4f91c318c39..ffe95c8d07d 100644 --- a/src/services/SyncService.ts +++ b/src/services/SyncService.ts @@ -5,7 +5,11 @@ import type { ShallowRef } from 'vue' import type { OpenData } from '../apis/connect.ts' -import type { Connection } from '../composables/useConnection.ts' +import type { Connection } from '../types/Connection.ts' +import type { Document } from '../types/Document.ts' +import type { ErrorType } from '../types/ErrorType.ts' +import type { GuestSession, Session, UserSession } from '../types/Session.ts' +import type { Step } from '../types/Step.ts' import mitt from 'mitt' import { close } from '../apis/connect.ts' @@ -13,6 +17,7 @@ import { push } from '../apis/sync.ts' import { logger } from '../helpers/logger.js' import { awarenessSteps } from '../helpers/steps.ts' import { documentStateToStep } from '../helpers/yjs.ts' +import { ERROR_TYPE } from '../types/ErrorType.ts' import Outbox from './Outbox.ts' import PollingBackend from './PollingBackend.ts' @@ -27,58 +32,6 @@ const COLLABORATOR_IDLE_TIME = 60 const COLLABORATOR_DISCONNECT_TIME = 90 -const ERROR_TYPE = { - /** - * Failed to save collaborative document due to external change - * collision needs to be resolved manually - */ - SAVE_COLLISION: 0, - /** - * Failed to push changes for MAX_REBASE_RETRY times - */ - PUSH_FAILURE: 1, - - LOAD_ERROR: 2, - - CONNECTION_FAILED: 3, - - SOURCE_NOT_FOUND: 4, - - PUSH_FORBIDDEN: 5, -} as const - -type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] - -/* - * Step as what we expect to be returned from the server right now. - */ -export interface Step { - data: string[] - version: number - sessionId: number -} - -export interface UserSession { - id: number - userId: string - color: string - lastAwarenessMessage: string - lastContact: number - documentId: number - displayName: string -} - -export interface GuestSession { - id: number - color: string - lastAwarenessMessage: string - lastContact: number - guestName: string - documentId: number -} - -export type Session = UserSession | GuestSession - /** * Test if a session is a guest session * @@ -97,14 +50,6 @@ export function isUser(session: Session): session is UserSession { return 'userId' in session && typeof session.userId === 'string' } -export interface Document { - id: number - lastSavedVersion: number - lastSavedVersionTime: number - baseVersionEtag: string - initialVersion: number -} - export declare type EventTypes = { /* Document state */ opened: OpenData diff --git a/src/services/WebSocketPolyfill.ts b/src/services/WebSocketPolyfill.ts index 1dcd3f90af7..d5a228c5171 100644 --- a/src/services/WebSocketPolyfill.ts +++ b/src/services/WebSocketPolyfill.ts @@ -4,7 +4,8 @@ */ import type { OpenData } from '../apis/connect.ts' -import type { Step, SyncService } from './SyncService.ts' +import type { Step } from '../types/Step.ts' +import type { SyncService } from './SyncService.ts' import { decodeArrayBuffer, encodeArrayBuffer } from '../helpers/base64.ts' import { logger } from '../helpers/logger.js' diff --git a/src/tests/helpers/updateFromContent.spec.ts b/src/tests/helpers/updateFromContent.spec.ts index a9ed551e850..d58c1211f29 100644 --- a/src/tests/helpers/updateFromContent.spec.ts +++ b/src/tests/helpers/updateFromContent.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Connection } from '../../composables/useConnection.js' +import type { Connection } from '../../types/Connection.ts' import { Collaboration } from '@tiptap/extension-collaboration' import { describe, expect, it } from 'vitest' diff --git a/src/types/Connection.ts b/src/types/Connection.ts new file mode 100644 index 00000000000..a8b66ee457d --- /dev/null +++ b/src/types/Connection.ts @@ -0,0 +1,13 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface Connection { + documentId: number + sessionId: number + sessionToken: string + baseVersionEtag: string + filePath: string + shareToken?: string +} diff --git a/src/types/Context.ts b/src/types/Context.ts new file mode 100644 index 00000000000..934e41d4e97 --- /dev/null +++ b/src/types/Context.ts @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface Context { + type: string + id: number +} diff --git a/src/types/Document.ts b/src/types/Document.ts new file mode 100644 index 00000000000..db419a35ae0 --- /dev/null +++ b/src/types/Document.ts @@ -0,0 +1,7 @@ +export interface Document { + id: number + lastSavedVersion: number + lastSavedVersionTime: number + baseVersionEtag: string + initialVersion: number +} diff --git a/src/types/ErrorType.ts b/src/types/ErrorType.ts new file mode 100644 index 00000000000..32b623c5944 --- /dev/null +++ b/src/types/ErrorType.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const ERROR_TYPE = { + /** + * Failed to save collaborative document due to external change + * collision needs to be resolved manually + */ + SAVE_COLLISION: 0, + /** + * Failed to push changes for MAX_REBASE_RETRY times + */ + PUSH_FAILURE: 1, + + LOAD_ERROR: 2, + + CONNECTION_FAILED: 3, + + SOURCE_NOT_FOUND: 4, + + PUSH_FORBIDDEN: 5, +} as const +export type ErrorType = (typeof ERROR_TYPE)[keyof typeof ERROR_TYPE] diff --git a/src/types/Session.ts b/src/types/Session.ts new file mode 100644 index 00000000000..96b858a55a4 --- /dev/null +++ b/src/types/Session.ts @@ -0,0 +1,25 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface UserSession { + id: number + userId: string + color: string + lastAwarenessMessage: string + lastContact: number + documentId: number + displayName: string +} + +export interface GuestSession { + id: number + color: string + lastAwarenessMessage: string + lastContact: number + guestName: string + documentId: number +} + +export type Session = UserSession | GuestSession diff --git a/src/types/Step.ts b/src/types/Step.ts new file mode 100644 index 00000000000..99c13001f0a --- /dev/null +++ b/src/types/Step.ts @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* + * Step as what we expect to be returned from the server right now. + */ + +export interface Step { + data: string[] + version: number + sessionId: number +} diff --git a/src/views/CollaborativeEditorApp.vue b/src/views/CollaborativeEditorApp.vue index 6a10582e3e6..c686b2d3f98 100644 --- a/src/views/CollaborativeEditorApp.vue +++ b/src/views/CollaborativeEditorApp.vue @@ -24,7 +24,7 @@