Skip to content

Make the onlyoffice settings take effect: formats and the Save button - #387

Merged
chadek merged 16 commits into
nextcloud:masterfrom
chadek:fixOnlyofficeSettings
Sep 15, 2026
Merged

chadek merged 16 commits into
nextcloud:masterfrom
chadek:fixOnlyofficeSettings

Conversation

@chadek

@chadek chadek commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

Started as two onlyoffice admin settings that say something is happening and, on this server, are not: the format matrix and the editor's Save button. It then grew a review pass over this branch and over master, and now also carries fourteen fixes found that way — including one that loses a change under simultaneous editing.

Rebased on master — the other two commits this branch used to carry are in already, by way of #396, whose versions are better, so they are gone from here. The chat fix landed separately as #389.

Sixteen commits, each doing one thing.

1. Stop overwriting the admin's format settings on every request

AutoConfig::autoConfigIfNeeded() runs from boot() — so on every request — and re-applied syncSupportedFormats() against a hardcoded list. Anything the admin enabled that the list did not mention was switched back off by the next page load:

admin enables pdf/docm/html editing:   pdf=True  docm=True  html=True
-> loading the files app once:         pdf=False docm=False html=False

The list had also gone stale. It predated 9.x and named twelve editable formats where the bundled server declares twenty-nine — and three of the twelve (doc, ppt, xls) it cannot edit at all, only auto-convert.

Formats are now seeded once, at initial auto-config, from the format matrix the bundled server ships, and never touched again; from there the admin owns them. Seeding still earns its keep, because the connector leaves the lossy-editable formats (odt, ods, odp, csv, rtf, txt) off by default — that was the point of doing it at all. Default openers stay a curated ten rather than following capability, since making OnlyOffice the default handler for .txt or .html would fight Nextcloud's own apps.

A fresh install now seeds 29 editable formats where it seeded 12.

Reading the matrix out of the package is lib/OnlyOffice/BundledFormats.php, whose path is injectable so the unit test does not need the document server to have been built.

2. Make the editor's Save button work

"Keep intermediate versions when editing" sets editorConfig.customization.forcesave, which gives the editor a Save button. Pressing it sends a forceSaveStart socket command the dispatcher had no handler for, so the command was dropped and the button did nothing — the setting looked broken from the settings UI.

The new handler answers with the two-part reply the client expects (forceSaveStart then forceSave). Both carry the same timestamp, because the client stores the one from forceSaveStart and ignores a forceSave whose time does not match it. The save is synchronous, so a failure is reported as an error rather than as a save that starts and never lands, and a press with nothing typed since the last write is answered NotModified — the editor's own state for that — rather than as a save that did not happen.

It snapshots rather than flushes. flushChanges() is written for a session that is ending: it consumes the change list and, when no session is left, closes the document. Driving that from the Save button meant doing it to a document people are still typing into — the change list is the only record of what was typed, since Editor.bin stays at the version the document was opened at, so a session opening next would render the document from before the save and the eventual flush would write a file that had lost the earlier edits.

Also adds documentserver:flush --snapshot, which is that same write on its own: for cron at a chosen interval, or to get every open document on disk on demand.


The rest came out of reviewing this branch and the release before it. Each is its own commit.

Losing work

changesIndex was the highest index where sdkjs wants a count

This is the one worth reading. sdkjs keeps this number as its synced position and derives absolute positions from it by adding its own offsets — on the wire deleteIndex += this.changesIndex, and locally Changes.length = SyncIndex + deleteIndex, where a length is plainly a count. We sent getMaxChangeIndexForDocument(), which is count - 1, so every position the client derived came back one too low.

Logging the incoming values on the rig showed the arithmetic exactly, twice over:

deleteIndex=350  maxBefore=266  nchanges=84     350 = 266 + 84
deleteIndex=330  maxBefore=246  nchanges=84     330 = 246 + 84

It hides because a save that carries changes still derives a position past the end of the store, so the delete matches nothing — those two probe runs showed rows before=247, rows after=247 and passed. But sdkjs also sends a saveChanges with an empty change array when all it has to say is a deleteIndex (0 < aChanges.length || null !== deleteIndex), and there the off-by-one is a request to delete one change that should have survived. One change, gone from the file and from the document when it is reopened.

The guard around the delete was wrong too. The key is absent from most saves and -1 when the client explicitly has nothing to discard — sdkjs guards its own arithmetic with -1 !== this.deleteIndex — and both reached deleteChangesByIndex(), because the key was read unguarded and the test was truthiness. -1 is truthy in PHP, so it asked for change_index >= -1: every change in the document. Never observed firing, but it is one client message away.

A document-wide cut is the right semantic, for the record — sdkjs converts to an absolute document index on purpose.

A document was never seen to be empty

getSessionsForDocument() counted every row for the document. Rows are only deleted by cleanSessions(), which runs from the background job, so between two job runs the table holds every session that ever stopped polling — a browser that was killed, a laptop that was closed. Counting those meant the last editor leaving never looked like the last editor leaving: SessionCloser::sessionLeft() did not write the file, and the write fell back to whenever cron next ran, which is exactly the wait #100 exists to remove. It also made expireSession() do nothing observable.

Two notes from getting this wrong on the way:

  • expireSession() backdates past the cutoff now instead of writing zero. Zero happens to be far enough in the past against a real clock, but it says nothing about why — and a test with a small clock found it out.
  • A departing editor's row is deleted, not expired, and the docblocks now say why, because the two look interchangeable and are not. The poll that session left behind keeps running server-side for up to Channel::TIMEOUT and marks it as seen every Channel::SEEN_INTERVAL, so an expired row revives itself out of its own orphaned request. I tried expiring — every unit test passed, and the rig failed with "B lost the document server when A left".

Saving

  • documentserver:flush finishes its sweep. Returning at the first failure meant one document the converter chokes on left every document after it unwritten, silently — the opposite of what somebody running it before a backup wants. Each is attempted now, failures go to stderr as well as the log, and the exit code says whether it went cleanly.
  • The Cleanup job honours autosave_interval. It called saveSnapshot() directly, so it ignored the interval — including the 0 documented as turning the periodic write off — and turned one cron pass into a converter run for every open document.
  • The Save button is floored, and refused to read-only sessions. Assembling a document is a converter run, and forceSaveStart is the one place a client asks for one directly: the periodic write is floored at autosave_interval, a command is whatever arrives on the socket. A client sending it after every change turns each keystroke into an x2t run. The floor is deliberately not an app setting, because autosave_interval 0 must not silence the button.

Hardening

  • Chat messages are capped where they are stored. The handler took whatever string arrived; the editor's own limit is a maxlength on a textarea, which is no limit for anything that is not the editor. Capped at sdkjs c_oAscMaxCellOrCommentLength and cut by characters, because truncating mid-sequence puts invalid UTF-8 in the history file and json_encode() returns false on that — losing the whole backlog rather than one message.
  • The close beacon is rate limited, and sent once. Nothing authenticates /session-closed but the session id and it holds a worker for the grace period. pagehide and unload both fired for the same departure, so the server held a worker through the grace period twice.
  • Memcache IPC miss counters expire. inc() is the only atomic counter IMemcache offers and it takes no TTL, and cleanupChannel() removed only the read and write pointers — so a counter for a slot never resolved sat in the cache for the life of the process.
  • A missing api.js answers 404 instead of a warning and a 200 with nothing in it.

Build and schema

  • The document server package is verified before it is unpacked. The build downloads ~600 MB of native code, including the x2t the web server executes, and took whatever arrived. ds_sha256 is pinned and checked first; a pinned hash also catches a release re-uploaded under the same tag, which the digest the API reports alongside the url cannot. The asset url is matched against browser_download_url rather than the first https://...x86_64.rpm anywhere in the response, and the extracted tree is no longer chmod 777 — that shipped a world-writable document server, so any local account could replace the converter.
  • The change index's unique constraint is in an order queries can use. (change_index, document_id) led with the column nothing filters on. Measured on the rig with 2001 rows: the old order plans Using where; Using index over all of them, the new one Select tables optimized away.
  • The app version moves to 0.3.4. Nextcloud only replays migrations when the declared version increases, and the tag v0.3.3 still carried 0.3.2 inside — so neither the index migration above nor Drop the database indexes that duplicate a wider one #395's would have run on an upgrade.
  • Existing installs get the corrected format matrix. Seeding only runs while the connector has no url, so the fix at the top of this PR reaches new installs and nothing else. A repair step re-seeds an install that still carries exactly what the old hardcoded list wrote, and leaves every other install alone — the old code force-wrote the matrix on every request except where the admin disabled something, and that asymmetry is what makes it safe to decide. It resolves AutoConfig from the container behind an isEnabledForUser guard rather than taking it as a constructor argument: with onlyoffice disabled, an eagerly injected one makes occ upgrade die with Class "OCA\Onlyoffice\AppConfig" does not exist.
  • BundledFormats is registered rather than autowired — its one constructor argument is a string, and the container resolves builtin-typed parameters by parameter name before falling back to the default.

Testing

phpunit, green in CI: 86 tests, 232 assertions. New files:

SaveChangesCommandTest changesIndex is a count; -1 and an absent key delete nothing; a later chunk does not re-apply the delete
SessionManagerTest (+) an expired session is not a participant; polling keeps one; expireSession drops it at once and a poll revives it
SessionCloserTest a leaving session is dropped, a session that stopped co-authoring is expired; the document is written when the last one leaves and not before
ChangeStoreTest the returned index is where the batch actually landed, including after another writer got in first
FlushChangesTest the sweep finishes past a document that will not assemble, and the exit code reports it
CleanupTest a live document goes through the autosave interval; one that will not write does not stop the sweep
ChatMessageTest an overlong message is cut to the editor's own limit, and the cut leaves valid UTF-8
ForceSaveTest (+) the write is floored rather than run on demand; a read-only session cannot ask for one
SaveHandlerTest (+) a throttled write is refused inside its interval and not disabled by autosave_interval 0

Every one of these was run against the unfixed code first, so they fail for the reason they exist.

tests/rig, all ten green on a Nextcloud 34 rig with the bundled server:

PASS  formats         67s      PASS  twotab         278s
PASS  smoke          221s      PASS  coedit         201s
PASS  flush-live     165s      PASS  coedit-xlsx    143s
PASS  autosave       190s      PASS  forcesave      220s
PASS  leave          292s      PASS  chat           190s

leave is the one that matters here: it was failing intermittently, roughly two runs in six, with one marker missing from the file and from the reopened document. That is the changesIndex off-by-one. It has passed three consecutive times since.

formats_test gained a fourth section that drives two real occ upgrade runs, because repair steps are not part of occ maintenance:repair — an app's installed version being behind info.xml is the only thing that carries them.

Notes for reviewers

  • The commits are independent and each lints on its own.
  • occ boots this app, so an occ call made while the connector is unconfigured re-runs auto-configuration. That is why formats_test deletes the connector's url last and puts it back afterwards; it is also why clearing the config with occ config:app:delete to test a fresh install does not work the way you would expect.
  • The shipped server/Common/config/default.json is not involved in any of this — nothing in lib/ reads it. It belongs to the Node DocService this app replaces, so it can never be the reason a setting appears not to work.
  • A failed rig run leaves polls in flight for up to 25s, and Channel::getResponse() re-creates a session whose row it cannot find — so a reset straight after a test that died mid-edit can be followed by rows appearing from nowhere. Wait it out, or restart the app container, before trusting a session count.

@chadek
chadek force-pushed the fixOnlyofficeSettings branch from 50c330c to 2d03c5d Compare September 5, 2026 09:22
chadek and others added 2 commits September 7, 2026 10:18
AutoConfig::autoConfigIfNeeded() runs from boot(), so on every request, and it
re-applied syncSupportedFormats() against a hardcoded list. Anything the admin
had enabled that the list did not mention was switched back off by the next page
load:

    admin enables pdf/docm/html editing:   pdf=True  docm=True  html=True
    -> loading the files app once:         pdf=False docm=False html=False

The list had also gone stale. It predated 9.x and named twelve editable formats
where the bundled server declares twenty-nine - and three of the twelve (doc,
ppt, xls) it cannot edit at all, only auto-convert.

Formats are now seeded once, at initial auto-config, from the format matrix the
bundled server ships, and never touched again; from there the admin owns them.
Seeding still earns its keep, because the connector leaves the lossy-editable
formats (odt, ods, odp, csv, rtf, txt) off by default - that was the point of
doing it at all. Default openers stay a curated ten rather than following
capability, since making OnlyOffice the default handler for .txt or .html would
fight Nextcloud's own apps.

A fresh install now seeds 29 editable formats where it seeded 12.

Reading the matrix out of the package is BundledFormats, which is also where the
unit test can get at it without the connector installed.
tests/rig/formats_test.py covers the three things end to end: the seed a fresh
install gets, an admin choice surviving page loads, and the setting reaching the
editor - the connector's own config response, where a format decision shows up
as editorConfig.mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
"Keep intermediate versions when editing" sets
editorConfig.customization.forcesave, which gives the editor a Save button.
Pressing it sends a forceSaveStart socket command the dispatcher had no handler
for, so the command was dropped and the button did nothing - the setting looked
broken from the settings UI.

The new handler answers with the two-part reply the client expects
(forceSaveStart then forceSave). Both carry the same timestamp, because the
client stores the one from forceSaveStart and ignores a forceSave whose time
does not match it. The save is synchronous, so a failure is reported as an error
rather than as a save that starts and never lands, and a press with nothing
typed since the last write is answered NotModified - the editor's own state for
that - rather than as a save that did not happen.

It saves a snapshot rather than flushing. flushChanges() is written for a
session that is ending: it consumes the change list and, when no session is
left, closes the document. Driving that from the Save button meant doing it to a
document people are still typing into - the change list is the only record of
what was typed, since Editor.bin stays at the version the document was opened
at, so a session opening next would render the document from before the save and
the eventual flush would write a file that had lost the earlier edits.

Also adds `documentserver:flush --snapshot`, which is that same write on its
own: for cron at a chosen interval, or to get every open document on disk on
demand.

Covered by tests/XHRCommand/ForceSaveTest.php for the reply protocol and
tests/rig/forcesave_test.py end to end - what the editor was told, the file on
disk, a session opened afterwards, and the file again once everyone has left.
flush_live_test gains the --snapshot step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
@chadek
chadek force-pushed the fixOnlyofficeSettings branch from 2d03c5d to 434d257 Compare September 7, 2026 13:18
@chadek chadek changed the title Make the onlyoffice settings take effect: formats, Save button, chat Make the onlyoffice settings take effect: formats and the Save button Sep 7, 2026
chadek and others added 14 commits September 15, 2026 00:39
documentserver_change_doc_id is unique over (change_index, document_id).
Uniqueness does not care about the order but every query does, and the
leading column was the one nothing filters on: both
`SELECT MAX(change_index) WHERE document_id = ?` and the delete behind
deleteIndex had to fall back to an index that only narrows by document
and then scan what it found.

That was affordable when the max was read once per save. It is read
several times per save now, plus once per snapshot, so the same pair the
other way round turns the read into a lookup - and costs nothing, because
it replaces an index rather than adding one. Measured on the rig with
2001 rows: the old order plans "Using where; Using index" over all of
them, the new one "Select tables optimized away".

The app version has to move for any migration to run at all - Nextcloud
only replays them when the declared version increases, and the tag v0.3.3
still carried 0.3.2 inside - so it goes up here, with the first change
that needs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The build downloads ~600 MB of native code, including the x2t binary the
web server executes, and took whatever arrived. Pin the sha256 of the
asset for ds_version and check it before anything reads the file; a
pinned hash also catches a release re-uploaded under the same tag, which
the digest the API reports alongside the url cannot. Override ds_sha256
together with ds_version, or set it empty to build unverified.

Two smaller things in the same step. The asset url is now matched against
browser_download_url rather than the first https://...x86_64.rpm anywhere
in the response, which need not be an asset of the release at all. And
the extracted tree is no longer chmod 777: that shipped a world-writable
document server, so any local account could replace the converter the web
server runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Its one constructor argument is a string with a default, and Nextcloud's
container resolves builtin-typed parameters by *parameter name* before
falling back to that default - so a service registered under the name
"path" would be handed to it as a file path. Registering it removes the
coupling; the constructor argument stays for the tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Seeding runs from AutoConfig::autoConfig(), which only runs while the
connector has no document server url - so it reaches new installs and
nothing else. An instance configured before the seed was read from the
bundled package keeps what the old hardcoded list wrote: twelve editable
formats, three of which (doc, ppt, xls) the bundled server cannot edit at
all, and none of the twenty-nine it can.

The decision of whether an install may be re-seeded is deliberately
conservative. The old code force-wrote the matrix on every request, so an
install that ran it holds exactly the hardcoded lists - except where the
admin *disabled* something, which survived because the write was an AND
against what was already there. That asymmetry is what makes this safe to
decide: anything other than the two lists exactly is a choice somebody
made, and is left alone.

AutoConfig is resolved from the container inside run(), behind an
isEnabledForUser guard, rather than injected: building it needs the
connector's own AppConfig class, so with onlyoffice disabled an eagerly
injected one makes `occ upgrade` die with `Class "OCA\Onlyoffice\AppConfig"
does not exist`. Verified on the rig, which is also where formats_test now
drives two real `occ upgrade` runs - repair steps are not part of
`occ maintenance:repair`, so an app's installed version being behind
info.xml is the only thing that carries them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Assembling a document is a converter run, and forceSaveStart is the one
place a client asks for one directly: the periodic write is floored at
autosave_interval, a command is whatever arrives on the socket. A client
that sends forceSaveStart after every change turns each keystroke into an
x2t run, because a stored change is exactly what stops the write being
skipped as unmodified. SaveHandler::saveSnapshotThrottled() puts a floor
under a write somebody asked for, kept separate from saveSnapshotIfDue()
because `autosave_interval 0` must not silence the Save button - the admin
turned off the periodic write, not the feature.

A session that cannot edit has nothing to save and no Save button either,
so forceSaveStart from one is not the editor asking. It is answered the
way an unmodified document is, so nothing hangs, and not acted on. No
other command handler checks isReadOnly - read-only is enforced in the
browser only - but this one hands out converter runs, so it checks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The command sweeps every open document and it is the thing an admin runs
before a backup or from cron. Returning at the first failure meant one
document the converter chokes on left every document after it unwritten,
silently, since the exit code was all that said so - the opposite of what
somebody running it before a backup is asking for.

Each document is attempted now, failures are reported on stderr as well
as logged, and the exit code says whether the sweep went cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The job called saveSnapshot() directly, so it ignored autosave_interval -
including the 0 that is documented as turning the periodic write off - and
turned one cron pass into a converter run for every open document on a
busy instance. This is the periodic write arriving by another road, so it
goes through the same interval the editing path uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
getSessionsForDocument() counted every row for the document. Rows are only
deleted by cleanSessions(), which runs from the background job, so between
two job runs the table holds every session that ever stopped polling - a
browser that was killed, a laptop that was closed. Counting those meant a
document was never seen to be empty: SessionCloser::sessionLeft() would
not write the file when the last editor left, and the write fell back to
whenever the job next ran, which is exactly the wait nextcloud#100 exists to
remove. It also made expireSession() do nothing observable.

The cutoff is the one cleanSessions() deletes by, so this only stops
counting a session that was already on its way out; one that is genuinely
polling says so every Channel::SEEN_INTERVAL seconds, six times over
inside the timeout.

expireSession() now backdates past that cutoff instead of writing zero.
Zero happens to be far enough in the past against a real clock, but it
says nothing about why and only works while the clock is large - which a
test with a small clock found out.

The docblocks on removeSession() and sessionLeft() say why a departing
editor's row is deleted and not expired, because the two look
interchangeable and are not: the poll that session left behind keeps
running server-side for up to Channel::TIMEOUT and marks it as seen every
Channel::SEEN_INTERVAL, so an expired row revives itself out of its own
orphaned request and the document is never disposed of. Deleting is what
makes that marking a no-op. A client that only stopped co-authoring is the
other case - its page is still there and still polling, and reviving is
what should happen.

harness.sessions() counts participants the same way, because every test
that calls it means "who is still editing", not "how many rows have not
been garbage-collected yet".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
popMessage() counts how often a claimed slot is found empty, and inc() is
the only atomic counter IMemcache offers - it takes no TTL, and no
Nextcloud backend gives one to a key it creates. cleanupChannel() removes
only the read and write pointers, so a counter for a slot that is never
resolved - the session ended while a push was in flight - sat in the cache
for the lifetime of the process. The messages themselves already expire;
their bookkeeping now gets the same lifetime where the backend supports
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The handler took whatever string arrived on the socket. The editor's own
limit is a maxlength on a textarea, which is no limit at all for anything
that is not the editor, and the history is capped at a hundred *messages*
with nothing said about their size - so a scripted client could park as
much in a document's appdata as the request limit allows.

Capped at sdkjs c_oAscMaxCellOrCommentLength, the editor's own number, and
cut by characters rather than bytes: truncating mid-sequence would put
invalid UTF-8 in the history file, and json_encode() returns false on
that, which would lose the whole backlog rather than one message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
The branch that appends the close beacon reads the file itself, so it
skipped the existence check createFileResponse() does. On a build where
api.js was never rendered from its .tpl, file_get_contents() on a missing
file turned a 404 into a warning and a 200 with nothing in it. The
setup-check branch above still runs either way, because that is the one
that explains to the user why the document server is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Nothing authenticates /session-closed but the session id, and it holds a
worker for the grace period, so cap what one caller can spend: twenty a
minute is generous next to what an editor does - a session says goodbye
once - and low enough that guessing at session ids is not free.

Once, though, is what the page was not doing. pagehide and unload both
fire for the same departure and both sent a beacon, so the server held a
worker through the grace period a second time only to find the session
already gone. The frame now remembers the id it has said goodbye for, the
way the host half already did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
addChangesForDocument() reads the max inside its own transaction and
retries a batch that collides, so only it knows where the changes landed.
SaveChangesCommand worked the numbers out from a max it read beforehand,
outside that transaction - which is wrong exactly when a save raced
another one, the case the retry exists for.

Nothing on the wire moves: Change::formatForClient() does not emit the
change index, and sdkjs never reads startIndex out of the broadcast
either. This is the guess removed, not a protocol change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
…index

sdkjs keeps this as its synced position and derives absolute positions
from it by adding its own offsets: on the wire
`deleteIndex += this.changesIndex`, and locally
`Changes.length = SyncIndex + deleteIndex`, where a length is plainly a
count. We sent getMaxChangeIndexForDocument(), which is count - 1, so
every position the client derived came back one too low. Logging the
incoming values on the rig showed the arithmetic exactly:
deleteIndex == maxBefore + nchanges, twice over (350 = 266 + 84,
330 = 246 + 84).

It hid because a save that carries changes still derives a position past
the end of the store, so the delete matches nothing - probe runs showed
"rows before=247, rows after=247" and passed. But sdkjs also sends a
saveChanges with an *empty* change array when all it has to say is a
deleteIndex (`0 < aChanges.length || null !== deleteIndex`), and there the
off-by-one is a request to delete one change that should have survived.
That is the intermittent lost marker in leave_test: one change, gone from
the file and from the document when it is reopened.

The guard around the delete was wrong too. The key is absent from most
saves and -1 when the client explicitly has nothing to discard - sdkjs
guards its own arithmetic with `-1 !== this.deleteIndex` - and both
reached deleteChangesByIndex(), because the key was read unguarded and the
test was truthiness. -1 is truthy in PHP, so it asked for
`change_index >= -1`: every change in the document.

A document-wide cut is the right semantic, for the record - sdkjs converts
to an absolute document index on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
chadek added a commit to chadek/documentserver_community that referenced this pull request Sep 15, 2026
The bundled document server is Euro-Office rather than ONLYOFFICE
DocumentServer as of nextcloud#386, which is a change of what the app ships rather
than a fix to how it ships it, so the minor moves.

It also carries the two migrations added since v0.3.3 - Nextcloud only
replays those when the declared version increases - and the repair step
that hands an install configured before nextcloud#387 the format matrix a fresh one
is seeded with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
chadek added a commit that referenced this pull request Sep 16, 2026
The bundled document server is Euro-Office rather than ONLYOFFICE
DocumentServer as of #386, which is a change of what the app ships rather
than a fix to how it ships it, so the minor moves.

It also carries the two migrations added since v0.3.3 - Nextcloud only
replays those when the declared version increases - and the repair step
that hands an install configured before #387 the format matrix a fresh one
is seeded with.

Signed-off-by: Fabrice Meyer <meyer.fabrice@gmx.fr>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant