* fix(supersync): exclude legacy REPAIR from retention cleanup pruning
The daily-cleanup fallback (used when `latestFullStateSeq` is absent, i.e.
legacy/pre-marker installs) selected the pruning boundary with a raw
`opType: { in: [SYNC_IMPORT, BACKUP_IMPORT, REPAIR] }` filter that includes
legacy REPAIR rows (`repairBaseServerSeq` NULL). Such a repair carries no
causal base cursor proving its state is current as of its seq, so pruning
history behind it can permanently drop ops committed between its logical
base and its seq for any device replaying from before it.
#8971 migrated five full-state queries to `CAUSAL_FULL_STATE_OPERATION_WHERE`
but missed this one — the single query whose result directly authorizes a
DELETE. Route it through the same causal-only predicate; a legacy-repair-only
user then yields `protectedFromSeq === null` and is skipped (no deletion),
the safe default already implemented below.
Adds a regression test whose mock `findFirst` honours the causal
`repairBaseServerSeq` predicate.
* fix(supersync): gate misc→tasks conflict alias on the split boundary
`detectConflictForEntity` and `prefetchLatestEntityOpsForBatch` looked up
the legacy `GLOBAL_CONFIG:misc` alias for an incoming `tasks` write with
`schemaVersion < CURRENT_SCHEMA_VERSION`. Once v4 shipped, that aliases
post-split v2/v3 misc writes — disjoint from `tasks` — and fabricates false
`CONFLICT_CONCURRENT` rejections of tasks-settings writes for multi-device
users.
Gate both read-side lookups on the fixed `MISC_TASKS_SPLIT_SCHEMA_VERSION`
(2), matching the `isLegacyMiscConfigOperation` incoming gate and the
warning comment it already carries. Drops the now-unused
`CURRENT_SCHEMA_VERSION` import.
Adds real-PostgreSQL integration coverage for both changed lookups
(`detectConflictForEntity` and the batch `prefetchLatestEntityOpsForBatch`):
a post-split v2/v3 misc write must not alias, a legacy v1 one still must.
* fix(sync): re-upload LWW local-win ops after a WS-triggered download
A WS-triggered download that resolves a conflict against pending local ops
appends LWW local-win replacement ops straight to the op-log, bypassing the
capture effect. Unlike `ImmediateUploadService` and the main sync loop, this
path had no follow-up upload, so the preserved edit sat unsynced until the
next user edit or periodic sync — an unbounded window for manual-sync-only
users.
Re-upload when `localWinOpsCreated > 0`, mirroring the other two paths, and
surface the same terminal outcomes `ImmediateUploadService` does — permanent
rejection / rejected-full-state baseline → ERROR, mandatory-but-missing key →
UNKNOWN_OR_CHANGED — so a preserved edit that fails to converge is not left
silent (the WS path never claims IN_SYNC). Single follow-up only, matching the
sibling side channel.
* fix(supersync): validate the primary latestFullStateSeq marker as causal before pruning
The scheduled retention cleanup trusted `state.latestFullStateSeq` as a pruning
boundary whenever it was `<= lastSnapshotSeq`, with no check that the marked op is
a causal full-state operation. The earlier fix (
|
||
|---|---|---|
| .. | ||
| archive/encryption-attempts-openvz-incompatible | ||
| docs | ||
| helm/supersync | ||
| prisma | ||
| public | ||
| scripts | ||
| src | ||
| tests | ||
| tools | ||
| .env.example | ||
| .gitignore | ||
| Caddyfile | ||
| docker-compose.build.yml | ||
| docker-compose.monitoring.yml | ||
| docker-compose.yml | ||
| DOCKER-MONITORING.md | ||
| Dockerfile | ||
| Dockerfile.test | ||
| env.example | ||
| package.json | ||
| privacy-policy-en.md | ||
| privacy-policy.md | ||
| README.md | ||
| sync-server-architecture-diagrams.md | ||
| terms-of-service-en.md | ||
| terms-of-service.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
| vitest.integration.config.ts | ||
SuperSync Server
A custom, high-performance synchronization server for Super Productivity.
Note: This server implements a custom operation-based synchronization protocol (Event Sourcing), not WebDAV. It is designed specifically for the Super Productivity client's efficient sync requirements.
Related Documentation:
- Authentication Architecture - Auth design decisions and security features
- Operation Log Architecture - Client-side architecture
- Server Architecture Diagrams - Visual diagrams
- Backup & Disaster Recovery - Backup setup and recovery procedures
Architecture
The server uses an Append-Only Log architecture backed by PostgreSQL (via Prisma):
- Operations: Clients upload atomic operations (Create, Update, Delete, Move).
- Sequence Numbers: The server assigns a strictly increasing
server_seqto each operation. - Synchronization: Clients request "all operations since sequence
X". - Snapshots: The server can regenerate the full state by replaying operations, optimizing initial syncs.
Key Design Principles
| Principle | Description |
|---|---|
| Server-Authoritative | Server assigns monotonic sequence numbers for total ordering |
| Client-Side Conflict Resolution | Server stores operations as-is; clients detect and resolve conflicts |
| E2E Encryption Support | Payloads can be encrypted client-side; server treats them as opaque blobs |
| Idempotent Uploads | Request ID deduplication prevents duplicate operations |
Quick Start
Docker (Recommended)
The easiest way to run the server is using the provided Docker Compose configuration.
Deploy hosts need Docker with the Compose plugin, curl, git, and jq.
The image revision check requires Docker Compose support for
docker compose config --format json.
# 1. Copy environment example
cp env.example .env
# 2. Configure .env (Set JWT_SECRET, DOMAIN, POSTGRES_PASSWORD)
nano .env
# 3. Deploy the stack and run database migrations
./scripts/deploy.sh
docker compose up is not a deployment substitute: container startup migrations
are disabled by default so app restarts cannot race the deploy migrator.
./scripts/deploy.sh runs prisma migrate deploy once before replacing the app
container, then brings the stack up and verifies the health endpoint.
Leave DATABASE_URL unset when using the bundled Postgres service. The default
connection uses postgres:5432; existing installs that already set
DATABASE_URL with db:5432 keep working because the Compose service exposes
db as a network alias.
Upgrade note: because
RUN_MIGRATIONS_ON_STARTUPdefaults tofalse,docker compose pull && docker compose up -dcan leave the app running against unapplied migrations. Use./scripts/deploy.shfor production updates, or./scripts/deploy.sh --buildfor local image builds.
deploy.sh verifies that the pulled/built supersync image has an
org.opencontainers.image.revision label matching the latest commit that
affects the SuperSync image inputs. This prevents host deploy scripts from
running migrations against a stale image, without requiring a new image for
unrelated repo commits. If you publish custom images, pass the same source
revision as VCS_REF during the Docker build or set
SUPERSYNC_SKIP_IMAGE_REVISION_CHECK=true only for a deliberate manual
override.
Some migrations use CREATE INDEX CONCURRENTLY, which can block on long-running
transactions on a busy database. Run deploys off-hours when applying schema
changes, and raise MIGRATION_TIMEOUT (seconds, default 900) if a large
table requires more time. Exit code 124 from deploy.sh means the migration
timed out — re-run after the blocking transaction clears.
If a deploy was interrupted after Prisma recorded a migration as failed, later
deploys can stop with P3009. Prisma can also stop migrations with P3018
when they contain CREATE/DROP INDEX CONCURRENTLY statements, which cannot run
in one transaction block. scripts/migrate-deploy.sh handles the safe
drop-then-create concurrent-index case generically: it resolves the failed row
when needed, applies the migration SQL outside Prisma migrate, marks the
migration applied, and retries migrate deploy.
Existing databases created before the
0_initbaseline: the migration chain now begins with a0_initbaseline that creates the base tables, so a brand-new database can be initialized from migrations alone. A database whose schema predates this baseline must tell Prisma which migrations its schema already reflects before the next deploy, ormigrate deploytries to recreate existing objects and fails (relation "users" already exists, orP3005 The database schema is not empty). This also applies to the unattended deploy paths (the Helmmigrate-dbinitContainer and the DockerRUN_MIGRATIONS_ON_STARTUP=truestartup), which fail loudly until baselined.
Database with prior Prisma migration history (the pre-
0_initmigrations are recorded in_prisma_migrations): mark only the baseline as applied.npx prisma migrate resolve --applied 0_initDatabase created with
prisma db push(no migration history): its schema already matches the latestschema.prisma, so baseline the whole chain by marking every existing migration as applied.for m in prisma/migrations/*/; do npx prisma migrate resolve --applied "$(basename "$m")" doneFresh databases need none of this —
migrate deployapplies0_initand the rest of the chain automatically.
For local prisma migrate dev shadow databases, apply migrations containing
CREATE INDEX CONCURRENTLY through prisma db execute outside the transaction
and then mark the migration applied, mirroring the production deploy workaround.
If DATABASE_URL points to an external PostgreSQL server, set
POSTGRES_SERVICE= to the empty value. deploy.sh then starts only the
app/proxy services with compose dependencies disabled so the bundled Postgres
container is not required. Prisma migrations still run against the configured
DATABASE_URL.
Payload byte backfill and batch uploads
The payload_bytes column must be fully backfilled before enabling batched
uploads in production. During a partial backfill, quota reconciles use a slower
fallback for old operation rows with payload_bytes = 0.
Run the backfill to completion:
npm run migrate-payload-bytes
In a source checkout before npm run build, use:
npm run migrate-payload-bytes:dev
Only then set both rollout flags:
SUPERSYNC_BATCH_UPLOAD=true
SUPERSYNC_PAYLOAD_BYTES_BACKFILL_COMPLETE=true
The server refuses to start with SUPERSYNC_BATCH_UPLOAD=true unless the
completion flag is also set.
Manual Setup (Development)
# Install dependencies
npm install
# Generate Prisma Client
npx prisma generate
# Set up .env
cp env.example .env
# Edit .env to point to your PostgreSQL instance (DATABASE_URL)
# Push schema to DB
npx prisma db push
# Start the server
npm run dev
# Or build and run
npm run build
npm start
Configuration
All configuration is done via environment variables.
| Variable | Default | Description |
|---|---|---|
PORT |
1900 |
Server port |
HOST |
0.0.0.0 |
Server bind address. Use :: for IPv6-only deployments. |
DATABASE_URL |
- | PostgreSQL connection string (e.g. postgresql://user:pass@localhost:5432/db) |
JWT_SECRET |
- | Required. Secret for signing JWTs (min 32 chars) |
PUBLIC_URL |
- | Required. Public URL used for email links (e.g. https://sync.example.com) |
CORS_ORIGINS |
https://app.super-productivity.com |
Allowed CORS origins |
SMTP_HOST |
- | SMTP Server for emails |
API Endpoints
Authentication
Register a new user
POST /api/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "yourpassword"
}
Response:
{
"message": "User registered. Please verify your email.",
"id": 1,
"email": "user@example.com"
}
Login
POST /api/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "yourpassword"
}
Response:
{
"token": "jwt-token",
"user": { "id": 1, "email": "user@example.com" }
}
Synchronization
All sync endpoints require Bearer authentication: Authorization: Bearer <jwt-token>
1. Upload Operations
Send new changes to the server.
POST /api/sync/ops
2. Download Operations
Get changes from other devices.
GET /api/sync/ops?sinceSeq=123
3. Sync Status (diagnostic)
Check sync status and storage info. Not used by the production client — intended for operator/debugging use.
GET /api/sync/status
Client Configuration
In Super Productivity, configure the Custom Sync provider with:
- Base URL:
https://sync.your-domain.com(or your deployed URL) - Auth Token: JWT token from login
Maintenance
Scripts
The server includes scripts for administrative tasks. These use the configured database.
# Delete a user account
npm run delete-user -- user@example.com
# Clear sync data (preserves account)
npm run clear-data -- user@example.com
# Clear ALL sync data (dangerous)
npm run clear-data -- --all
API Details
Upload Operations (POST /api/sync/ops)
Request body:
{
"ops": [
{
"id": "uuid-v7",
"opType": "UPD",
"entityType": "TASK",
"entityId": "task-123",
"payload": { "changes": { "title": "New title" } },
"vectorClock": { "clientA": 5 },
"timestamp": 1701234567890,
"schemaVersion": 1
}
],
"clientId": "clientA",
"lastKnownSeq": 100
}
Response:
{
"results": [{ "opId": "uuid-v7", "accepted": true, "serverSeq": 101 }],
"newOps": [],
"latestSeq": 101
}
Download Operations (GET /api/sync/ops)
Query parameters:
sinceSeq(required): Server sequence number to start fromlimit(optional): Max operations to return (default: 500)
Upload Snapshot (POST /api/sync/snapshot)
Used for full-state operations (BackupImport, SyncImport, Repair):
{
"state": {
/* Full AppDataComplete */
},
"clientId": "clientA",
"reason": "initial",
"vectorClock": { "clientA": 10 },
"schemaVersion": 1
}
Security Features
| Feature | Implementation |
|---|---|
| Authentication | JWT Bearer tokens in Authorization header |
| Timing Attack Mitigation | Dummy hash comparison on invalid users |
| Input Validation | Operation ID, entity ID, schema version validated |
| Rate Limiting | Configurable per-user limits |
| Vector Clock Sanitization | Limited to 50 entries, 255 char keys |
| Entity Type Allowlist | Prevents injection of invalid entity types |
| Request Deduplication | Prevents duplicate operations on retry |
Multi-Instance Deployment Considerations
When deploying multiple server instances behind a load balancer, be aware of these limitations:
Passkey Challenge Storage
Issue: WebAuthn challenges are stored in an in-memory Map, which doesn't work across instances.
Symptom: Passkey registration/login fails if the challenge generation request hits instance A but verification hits instance B.
Solution for multi-instance:
- Implement Redis-backed challenge storage
- Or use sticky sessions (less ideal)
Current status: A warning is logged at startup in production if in-memory storage is used.
Snapshot Generation Locks
Issue: Concurrent snapshot generation prevention uses an in-memory Map.
Symptom: Same user may trigger duplicate snapshot computations across different instances.
Impact: Performance only (no data corruption) - snapshots are deterministic.
Solution for multi-instance:
- Implement Redis distributed lock (optional, only for performance)
Single-Instance Deployment
For single-instance deployments, these limitations do not apply. The current implementation is fully functional and well-tested for single-instance use.
Security Notes
- Set JWT_SECRET to a secure random value in production (min 32 characters).
- Use HTTPS in production. The Docker setup includes Caddy to handle this automatically.
- Restrict CORS origins in production.
- Database backups are recommended for production deployments.