No description
  • JavaScript 92.1%
  • CSS 5.8%
  • HTML 1.6%
  • Dockerfile 0.5%
Find a file
bogac 13617df0ca Record the 8f6a69f deploy in HANDOVER.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Machine: BoStudio
2026-11-04 12:12:42 +11:00
caddy Rename OneTab Next to Arcula, and tabs.celen.cloud to arcula.celen.cloud 2026-10-16 21:08:15 +11:00
extension Rename OneTab Next to Arcula, and tabs.celen.cloud to arcula.celen.cloud 2026-10-16 21:08:15 +11:00
icons Drop the review transcripts and the unused icon family 2026-10-16 21:59:03 +11:00
public Soften the corners of tab icons in list mode 2026-11-01 13:59:44 +11:00
reviews Drop the review transcripts and the unused icon family 2026-10-16 21:59:03 +11:00
server Keep up to 1000 favicons, least recently used out first, and remember failures 2026-11-04 10:41:19 +11:00
test/dashboard Let a chosen icon fill its plate in the icon view 2026-10-31 16:09:07 +11:00
.dockerignore Switch server deploys to git pull over a read-only deploy key 2026-10-16 22:11:48 +11:00
.env.example Add service status, Docker containers and recent commits cards 2026-10-28 19:05:45 +11:00
.gitignore Drop the review transcripts and the unused icon family 2026-10-16 21:59:03 +11:00
.npmrc OneTab Next: self-hosted OneTab replacement (server, web UI, extension) 2026-08-11 12:19:54 +10:00
docker-compose.yml Add service status, Docker containers and recent commits cards 2026-10-28 19:05:45 +11:00
Dockerfile Rename OneTab Next to Arcula, and tabs.celen.cloud to arcula.celen.cloud 2026-10-16 21:08:15 +11:00
HANDOVER.md Record the 8f6a69f deploy in HANDOVER.md 2026-11-04 12:12:42 +11:00
package-lock.json Bring the docs in line with the completed rename 2026-10-16 21:19:06 +11:00
package.json Rename OneTab Next to Arcula, and tabs.celen.cloud to arcula.celen.cloud 2026-10-16 21:08:15 +11:00
README.md Arrange dashboard cards on a grid, with per-device layouts 2026-10-17 22:00:55 +11:00

Arcula

Self-hosted, synced replacement for the OneTab extension. A browser extension sends your open tabs to a small server (SQLite-backed); a web UI at your own domain lists, searches, restores, and deletes them from any device. No dependency on browser-vendor sync.

A note on naming

This project was called "OneTab Next" until October 2026 and is now Arcula. The rename covered the display name, the hostname (tabs.celen.cloud to arcula.celen.cloud), the deploy directory (~/onetab-next to ~/arcula), the backup destination (/mnt/backup/onetab-next-backup to /mnt/backup/arcula-backup), and the compose service, container and runtime user. Three things kept their old names on purpose, so finding them below is not a leftover to tidy up:

  • onetab.db — the database filename. Renaming it would mean the running container looks for a file that isn't there, in exchange for nothing a user ever sees.
  • onetab-YYYYMMDD-HHMMSS.db — the backup filename prefix, and the onetab-*.db pattern the retention find matches. Changing the prefix without changing the pattern (or vice versa) means old snapshots stop being pruned and accumulate silently, so both were left alone when the directory was renamed.
  • onetab_token — kept only as a one-time migration source in public/app.js. The live key is arcula_token; the fallback reads the old key once, rewrites it under the new name, and clears it. It can be deleted once every browser you care about has loaded the dashboard at least once.

References to "OneTab" as a product (this being a replacement for it) are also not renames waiting to happen.

Layout

server/       Express + better-sqlite3 API (tests in server/test/)
public/       Web UI served by the same server — nothing here that isn't meant to be public
extension/    Manifest V3 Chrome/Edge/Brave extension (tests in extension/test/)
test/         Tests for the web UI, kept OUT of public/ so they're never served
caddy/        Caddy reverse-proxy snippet
Dockerfile, docker-compose.yml, .env.example, .dockerignore

Requirements

Node 22 or newer, for local development and running the test suite — both server/package.json and the root package.json declare "engines": { "node": ">=22" }. This isn't an arbitrary pin: better-sqlite3 13.x's own package.json requires Node ≥22 (its prebuilt binaries aren't built for anything older), and the committed test suite's use of node --test's glob file arguments needs a recent enough runtime too. If your default shell resolves to an older Node (node --version), switch first (nvm use 22, asdf local nodejs 22, or similar) before running npm test or npm start in server/. The Docker image (Dockerfile) already pins node:24-alpine independently of whatever's in your local shell, so this only matters for working on the code directly, not for the deployed container.

Security model

Two bearer tokens, both set in .env:

  • API_TOKEN — full read/write/delete. Used by the extension and the web UI.
  • API_TOKEN_READONLY (optional) — GET-only. Hand this one to Homarr instead of the full token, so a leaked dashboard widget config can't delete your data.

The server refuses to start if API_TOKEN is missing, still set to the .env.example placeholder, or shorter than 24 characters — a copy-paste-and-forget deploy won't silently run with a guessable credential. Tokens are compared with a timing-safe, fixed-length digest comparison, not ===.

There's still one token per role rather than one per device — full per-device revocation would need a token table, hashing, and an admin UI. For a single-user home server that's more machinery than the threat model justifies; if a device is compromised, rotate API_TOKEN in .env and reconfigure the extension everywhere. Keep the token in Vaultwarden, referenced by name, not pasted into chat or committed anywhere.

The extension stores its token in chrome.storage.local, not .sync — sync would copy it to every browser signed into your Google account, and Chrome's own docs say neither storage is encrypted regardless. storage.local is the pragmatic minimum, not a strong guarantee; anything with access to your profile directory can read it.

Other things already handled: tab URLs are validated as http(s):// only on the way in and rendered through DOM APIs (not innerHTML) on the way out, so a crafted title or URL can't run script on the page that holds your token. Favicons are proxied and cached server-side rather than loaded directly from the source site — direct loading would leak the dashboard's IP, timing, and referrer to every site in your saved tab history. The proxy resolves the target hostname once, rejects it if that address is private/loopback/link-local/reserved, and then connects to that exact resolved address for the actual fetch — not the hostname again — so a hostile server can't pass the check with one DNS answer and serve from an internal address on the real connection (DNS rebinding). The response is streamed with a byte counter enforced while bytes arrive, not after buffering the whole thing, and one timeout covers the entire request, not just time-to-first-byte. Restored tabs open with noopener/noreferrer and the opener reference is explicitly cleared. Every save carries a client-generated request ID, persisted in the extension until the server confirms success, so a lost response, a stuck popup, or a double click all retry the same request instead of creating a duplicate group. PRAGMA foreign_keys = ON is set per-connection so the declared ON DELETE CASCADE actually does something.

The web UI still keeps its token in localStorage, unchanged — the DOM-rendering and CSP fixes above remove the concrete way something could have run script on that page, but they don't turn localStorage into a safe vault. Treat it the same as any other same-origin secret: a future script-injection bug elsewhere on the page could still read it. A short-lived, HttpOnly, Secure, SameSite session cookie with CSRF protection would close this properly; not implemented here.

[Mac] Push to Forgejo

Create an empty repo bogac/arcula on git.celen.cloud first, then:

cd /Users/bogac/dev/forgejo/arcula
git init
git add -A
git commit -m "Arcula: server, web UI, extension"
git branch -M main
git remote add origin https://git.celen.cloud/bogac/arcula.git
git push -u origin main

Don't put the Forgejo token in the remote URL — it persists in .git/config, shell history, and process listings. Either use SSH (git remote add origin git@git.celen.cloud:bogac/arcula.git if your key is on Forgejo) or let the HTTPS push prompt you for username/token interactively and cache it in the macOS Keychain rather than in a plaintext file:

git config --global credential.helper osxkeychain

Getting the code onto the server

Two paths. Use whichever suits; the rest of the deploy is identical.

Via Forgejo — the normal path. The server pulls over Forgejo's SSH on loopback (127.0.0.1:2222) using a key that exists only for this repo:

# [server]
ssh celen
ssh-keygen -t ed25519 -f ~/.ssh/arcula_deploy -N '' -C 'arcula deploy key (celen)'
cat ~/.ssh/arcula_deploy.pub

Add that public key in Forgejo under bogac/arcula → Settings → Deploy Keys, titled after the host that holds it (celen (read-only)), with "Enable Write Access" left unchecked. A per-repo read-only deploy key rather than a shared access token: it cannot push, it cannot reach any other repo, and revoking it affects nothing else.

# [server]
cat >> ~/.ssh/config <<'SSHCFG'

Host forgejo-local
    HostName 127.0.0.1
    Port 2222
    User git
    IdentityFile ~/.ssh/arcula_deploy
    IdentitiesOnly yes
SSHCFG
chmod 600 ~/.ssh/config
ssh -T forgejo-local          # should greet you by name
git clone forgejo-local:bogac/arcula.git ~/arcula

Via rsync from the Mac — fallback only, for when Forgejo is down:

# [Mac]
rsync -avz --delete \
  --exclude '.git' \
  --exclude 'node_modules/' --exclude 'data/' --exclude '.env' \
  --exclude '*.db' --exclude '*.db-wal' --exclude '*.db-shm' \
  ~/dev/forgejo/arcula/ celen:~/arcula/

--delete makes the server match the Mac exactly, so a file removed locally doesn't linger there. node_modules/ is excluded because the image builds its own from the lockfile, and .env/data/ because those are server-side state that must never be overwritten from a laptop.

--exclude '.git' is the important one, and it was learned the hard way. This rsync originally sent .git deliberately, so the server copy would be a real checkout. The effect was the opposite: --delete overwrote the server's git state inconsistently, so its .git drifted to an older commit while the working tree stayed current, and the two disagreed about where the server was. That went unnoticed until someone ran a git commit on the server, which forked a new commit off the stale HEAD and then failed to push. Keep the two mechanisms strictly separate — rsync moves files, git moves history, and neither touches the other's business.

[server] Deploy

ssh celen
cd ~/arcula

Generate the tokens straight into .env, without echoing them to the terminal (and therefore into scrollback, tmux buffers, and shell history):

# The umask is scoped to a subshell on purpose. Left set in the interactive
# shell it also applies to anything created later in the same session —
# notably `mkdir data` below, which then comes out 0700 and leaves your
# login user unable to even `ls` it without sudo.
(
  umask 077
  {
    echo "PORT=4180"
    echo "DB_PATH=/data/onetab.db"
    echo "API_TOKEN=$(openssl rand -hex 32)"
    echo "API_TOKEN_READONLY=$(openssl rand -hex 32)"
  } > ~/arcula/.env
)
chmod 600 ~/arcula/.env

Read them back one at a time only when you actually need one — for the extension, or to store them in Vaultwarden:

grep '^API_TOKEN=' ~/arcula/.env | cut -d= -f2          # full access — the extension and web UI
grep '^API_TOKEN_READONLY=' ~/arcula/.env | cut -d= -f2  # GET-only — Homarr

Store both in Vaultwarden by name. If you'd rather set ALLOWED_ORIGIN (only needed for a browser-side Homarr widget), append it to .env now — see .env.example for what it does.

The container runs as a fixed non-root user (UID/GID 10001), and ./data is a bind mount — that overrides whatever ownership the image set up internally, so Docker will otherwise create ./data as root-owned on first run and the container won't be able to write onetab.db. Create it with the right owner before the first up:

mkdir -p ~/arcula/data
sudo chown -R 10001:10001 ~/arcula/data

data/ ends up owned by 10001:10001. Whether your login user can read it depends on the umask in effect when mkdir ran: 0755 and you can ls it directly, 0700 and you need sudo ls. Either is fine for the container, which runs as 10001 and owns the directory — and 0700 is the tighter choice for something holding full browsing history. The backup command below already uses sudo, so nothing else changes.

(If you're upgrading a deployment that was already running — from before this fix, when the container ran as root — the existing data/onetab.db* files will be root-owned. Run the same chown command against them once before restarting; it's safe to run even if nothing needs fixing.)

docker compose up -d --build
docker compose logs -f arcula   # Ctrl+C once you see "listening on :4180"
docker compose ps                     # "starting" for ~30s (first healthcheck probe), then "healthy"

[server] Caddy

Append caddy/arcula.caddy to your main Caddyfile:

# `sudo cat ... >> file` would NOT work: the shell performs the >> redirect
# as your login user before sudo runs anything, and /etc/caddy/Caddyfile is
# root-owned. `sudo tee -a` does the appending as root instead.
sudo tee -a /etc/caddy/Caddyfile < ~/arcula/caddy/arcula.caddy   # adjust path to your actual Caddyfile location
sudo caddy validate --config /etc/caddy/Caddyfile
sudo caddy reload --config /etc/caddy/Caddyfile

DNS: point arcula.celen.cloud at 211.26.247.183 in IONOS — remember new subdomains default to the parking IP (74.208.236.91), so set it explicitly.

Add an Uptime Kuma monitor for https://arcula.celen.cloud/api/health (no auth required on that endpoint).

First-run verification

Three checks, in this order. The third is the one worth not skipping.

# [server] the container is up and the health probe reaches the database
docker compose ps                                  # "healthy", not just "running"
curl -fsS http://127.0.0.1:4180/api/health

# [Mac] the whole path works, including TLS and the proxy
curl -fsS https://arcula.celen.cloud/api/health

Then send a real group of tabs from the extension and confirm it appears in the dashboard.

The shutdown check. Stop the container cleanly and look at what it left behind:

# [server]
docker compose stop && ls ~/arcula/data/

You should see onetab.db and nothing else. If onetab.db-wal or onetab.db-shm are still there, the SIGTERM handler in server/server.js isn't being reached — which matters more than it looks: an unclosed WAL database means a file-level backup of onetab.db alone can capture a database with no rows in it, and it will open cleanly and report integrity_check: ok while doing so. If those files persist, check that init: true is present in docker-compose.yml and that the logs show SIGTERM received, shutting down. docker compose start to bring it back.

Run the backup once by hand afterwards and confirm the group count in the backup matches the dashboard, so you've exercised the whole path before you need it.

Deploying a change

Edit on the Mac, commit, push, then pull on the server:

# [Mac]
cd ~/dev/forgejo/arcula
npm test
git push
# [server]
cd ~/arcula
git status -sb                                    # must be clean
git pull --ff-only
docker compose config >/dev/null && docker compose up -d --build
docker compose ps
curl -fsS http://127.0.0.1:4180/api/health

--ff-only is deliberate. A deploy target should refuse to invent history rather than quietly producing a merge commit, which is how a server clone ends up on a branch that exists nowhere else.

The health check needs a moment: the container's HEALTHCHECK has a 10s start period, and a curl fired immediately after up -d will return 502 because Node isn't listening yet. That is not a failure.

⚠️ Never edit code on the server. A local modification to a tracked file makes every later git pull --ff-only abort, and because nothing shouts about it, the deployment silently stops receiving updates. The same mistake left a Streamlit app on this box running four-month-old code. git -C ~/arcula status should always be clean. If you need an emergency change, make it on the Mac and push, or use the rsync fallback and reconcile afterwards.

Two things that change the shape of a deploy:

  • If the Compose service name changes, docker compose down will not remove the old container, because down only knows about services defined in the current Compose file. The old container keeps holding 127.0.0.1:4180 and the new one fails to bind. Use docker compose down --remove-orphans.
  • If the directory is renamed, the Compose project name changes with it, since it's derived from the directory. Run down in the old directory before moving it, or the old project's containers and network are orphaned under a name the new directory can't see.

[Mac] Install the extension

  1. Chrome → chrome://extensions → enable Developer mode → Load unpacked → select /Users/bogac/dev/forgejo/arcula/extension.
  2. Click the extension icon → Options.
  3. Server URL: https://arcula.celen.cloud. API token: API_TOKEN from .env (the full one, not the read-only one). Save.
  4. Repeat on any other machine/browser you use — same server URL and token everywhere gives you sync.

Popup has Send all tabs (current window, skips pinned tabs by default) and Send current tab (always sends the active tab regardless of pinned state). Both go through the same background capture/close logic now, so behavior is consistent between them; a replacement tab (pointed at the Arcula web UI) is only opened if closing the sent tabs would otherwise leave the window empty. Optional keyboard shortcut: chrome://extensions/shortcuts, bind "Send all tabs in this window to Arcula" (defaults to Cmd+Shift+U on Mac) — if it fails, the extension icon shows a red badge for a few seconds (check the service worker console for the actual error).

If a send doesn't get a confirmed response (server unreachable, dropped connection, service worker restarted mid-request), the popup shows a banner on next open saying so, and both Send buttons will retry that exact request first — same tabs, same idempotency key — instead of capturing fresh tabs and risking a duplicate group. Retrying never closes tabs automatically, even once the save is confirmed: matching the tab's current URL against what was captured doesn't prove the tab's actual content is still what got saved (a form, draft, or single-page app can change substantially without its URL moving), so once real time has passed, the extension leaves the tabs for you to close by hand rather than guessing. If you'd rather abandon a pending attempt (e.g. it's stale and you've since closed those tabs, or saved them another way), the banner has a "discard" button — disabled while a send is actively in progress, so discarding can't race an attempt that's still running.

Homarr

Homarr has no native OneTab-style widget, so this isn't a "plugin" — it's two ordinary Homarr integrations:

  1. App tile: Add App → name "Arcula" → URL https://arcula.celen.cloud → place on your bogac.celen.cloud board. This is the link to the full management UI.

  2. Custom API widget for a live count: point it at GET https://arcula.celen.cloud/api/stats with header Authorization: Bearer <API_TOKEN_READONLY>. It returns:

    { "session_count": 12, "tab_count": 143, "latest_captured_at": "2026-08-10T09:15:00.000Z" }
    

    Use the read-only token here, not the full one — a widget config is a plausible leak point, and this token can't rename or delete anything. Verify the exact Custom API widget field names against your installed Homarr version before wiring it up; Homarr's schema has changed between releases.

Known limitations / deliberate trade-offs

  • host_permissions is still broad (https://*/*) so the extension can reach whatever domain you configure without a runtime permission prompt. optional_host_permissions would let it request only the configured origin, but that adds a runtime-grant flow for a single-user tool talking to one server you control — not implemented. Narrow it by hand in manifest.json if you want to.

  • No incognito capture: the extension doesn't request incognito access, so it simply won't see those tabs unless you separately enable "Allow in Incognito" for it — not a code guarantee, a Chrome default.

  • Full URLs and titles are captured as-is, including query strings and fragments — that's the point of a tab manager, but it means OAuth callback fragments, document IDs, and search terms end up in the database. Treat data/onetab.db as browsing history, not a bookmark list: back it up and restrict access accordingly.

  • Restore-all opens each saved tab via window.open in a loop. Browsers throttle beyond roughly 10-15 simultaneous popups even from a real click handler; for big groups, expand and restore a few at a time.

  • Backups: automated nightly since October 2026, and the restore has been verified once against a scratch copy. A one-liner in root's crontab runs at 20:30:

    30 20 * * * f="/mnt/backup/arcula-backup/onetab-$(date +\%Y\%m\%d-\%H\%M\%S).db" \
      && /usr/bin/sqlite3 /home/bogac/arcula/data/onetab.db ".backup '$f'" \
      && chmod 600 "$f" \
      && find /mnt/backup/arcula-backup -name 'onetab-*.db' -mtime +14 -delete
    

    Two things about editing that line. The \% escapes are load-bearing: crontab treats an unescaped % as a command/stdin separator, so losing an escape silently truncates the command. And the backup directory is drwx------ root-owned, which means a glob you type yourself is expanded by your shell before sudo runs and will fail with a misleading "No such file or directory" — use sudo bash -c 'ls /mnt/backup/arcula-backup/*.db', or drop the glob. Edit the cron by round-tripping sudo crontab -l through a file, diffing, then installing with sudo crontab <file>.

    The manual procedure below is still worth knowing, because it is the thing the cron automates and the thing you will reach for in a hurry. WAL mode means a plain file copy of onetab.db can be badly wrong, not just slightly stale: at low write volume the main file can sit at 4 KB with every actual row still in onetab.db-wal, so copying onetab.db alone can yield a database with no rows in it at all — and it will open cleanly and report integrity_check: ok. Use the sqlite3 .backup command below, which checkpoints first, and if you also run an rsync-style file backup, make sure it copies onetab.db-wal and onetab.db-shm alongside the main file or skips them entirely rather than capturing a torn set. Run this on the host (celen), not inside the container — the container image doesn't have the sqlite3 CLI installed, only the app's own better-sqlite3 library. Install it once with sudo apt install sqlite3 if it isn't already there. The container's /data is the host's ~/arcula/data (see the bind mount in docker-compose.yml), and that directory is owned by the container's UID (10001, not your login user), so the backup command needs sudo to read it. Compute the destination path in a shell variable first — ~ is only expanded by the shell outside quotes, and the destination sits inside SQLite's quoted .backup argument, so an unquoted ~ typed directly into that string wouldn't expand at all:

    backup_file="$HOME/arcula/backups/onetab-backup-$(date +%Y%m%d-%H%M%S).db"
    sudo mkdir -p "$(dirname "$backup_file")"
    sudo sqlite3 "$HOME/arcula/data/onetab.db" ".backup '$backup_file'"
    

    Backups go to a separate backups/ directory rather than next to the live .db/.db-wal/.db-shm files, so they don't get mixed up with each other. This matches your Vaultwarden backup method (sqlite3 .backup, not a raw file copy). Move the resulting file off-box; it's not encrypted at rest.

    Restoring — read this before you need it. The obvious restore (copy the backup over onetab.db) silently does nothing if a stale onetab.db-wal is sitting next to it: SQLite replays that WAL on top of the file you just restored, so you get the old data back, and PRAGMA integrity_check still reports ok. Nothing anywhere reports an error. The WAL files are removed when the database is closed cleanly, which the server now does on SIGTERM — but a backup taken from a crashed or SIGKILLed container can still leave them, so do all four steps every time:

    cd ~/arcula
    docker compose down                                   # 1. stop the writer; a clean stop checkpoints and removes the -wal
    sudo rm -f data/onetab.db-wal data/onetab.db-shm      # 2. remove any WAL left from an unclean stop — THIS is the step that gets skipped
    sudo cp backups/onetab-backup-YYYYMMDD-HHMMSS.db data/onetab.db
    sudo chown 10001:10001 data/onetab.db                 # 3. the container runs as UID 10001, not your login user
    docker compose up -d                                  # 4.
    docker compose logs --tail=20                         # confirm it started and didn't fall back to an empty database
    

    Verify the restore actually took by checking the group count in the dashboard, not just that the container is healthy. Worth rehearsing once on a copy while nothing is at stake.

  • No schema migration tooling. db.js does a one-shot column-add migration for client_request_id on older databases; any future schema change needs the same manual treatment.

  • The favicon proxy has no rate limiting. It's authenticated (needs a valid bearer token) and capped per-request (4s timeout, 128KB body), but nothing stops the same authenticated caller from hitting it in a tight loop. Fine for one person's browser rendering their own saved tabs; not something to expose more broadly without adding a limiter.

  • Card layouts live on the server, per named layout. On a window at least 700px wide the dashboard is a 24-column grid: drag a card by its header (or the ⠿ grip) to move it, and by its right edge, bottom edge or corner to resize it. Positions are stored in layout_items, one row per group per layout, and removed with the group. Every device uses the layout called shared unless it picks or creates its own in the Layout menu; that choice is kept in the browser's localStorage (arcula_layout), so a laptop and a big monitor can each keep their own arrangement. Narrower windows, phones included, show the plain single-column list and never touch a layout. The API is GET /api/layouts, PUT /api/layouts/:name/items (upserts only the listed groups), DELETE /api/layouts/:name, and layout=<name> on GET /api/sessions.

  • GridStack is vendored, not loaded from a CDN. The page's CSP is script-src 'self', so public/vendor/gridstack/ holds gridstack-all.js, gridstack.min.css and the MIT LICENSE from the gridstack npm package, version 13.3.0, copied unmodified. To update: npm pack gridstack@<version>, copy those three files from the tarball's package/ over the old ones, and re-run the dashboard tests, which load the real file. Check that a new version still applies styles through CSS variables rather than an injected <style> element, which the CSP would block. 13.x does. "Load more" requests exactly the next page via GET /api/sessions?limit=&cursorCreatedAt=&cursorId=, where the cursor names the last row already loaded rather than a row count. This means there's no ceiling on how many groups are reachable, and — unlike OFFSET pagination — a session inserted or deleted by another device between two page requests can't shift, duplicate, or skip a row, since "the next page" is defined relative to a specific row rather than a position in the table. Sorting is created_at DESC, id DESC, with id as an explicit tiebreaker so two sessions captured in the same millisecond (plausible with sync across multiple devices) still sort deterministically. A delete triggers a re-walk of however many pages were already loaded, in PAGE_SIZE chunks via the same cursor mechanism, rather than trusting the local cache to still match the server.