super-productivity/packages/super-sync-server
Miklos 67d1e32ffc
feat(focus-mode): focus screen UX overhaul (#7586)
* feat(focus-mode): focus screen UX overhaul

Major rework of the focus mode timer screens (#7349):

- Shared <focus-clock-face> drives Pomodoro / Flowtime / Countdown /
  Break with a single visual chrome; size tokens scale fluidly via
  clamp() + vmin/vh, no discrete breakpoints.
- Single source of truth: --clock-time-size and --control-offset
  derive from --clock-face-size.
- Countdown: click-to-edit duration, draggable handle on the ring,
  hybrid 5-min/15-min snap with 6h-per-rotation above 1h
  ([useFlexibleIncrement] on input-duration-slider, opt-in for other
  consumers).
- Pomodoro prep inherits the click-to-type input; drag handle hidden
  so dragging the ring can't silently shift the value.
- Pause keeps the selected task on screen (displayedTask falls back
  to the paused task while currentTask is null).
- Notes panel acts as a modal: clock stays put, backdrop dims and
  closes on outside-click.
- Session controls row below the circle (pause / complete / reset
  cycles); buttons fade via shared --revealed-opacity gated on host
  hover + document.hasFocus().
- Break screen mirrors focus-mode-main layout; cycle counter inside
  the circle on both focus and break; back-to-planning unified.
- Flowtime settings dialog: all fields render with proper
  disable/enable, stable dialog width when switching modes.
- arrow_backward -> arrow_back: fixes glitched glyph in repeat-type
  context menus (boards, simple counters, take-a-break, flowtime).

* fix(focus-mode): silence naming-convention lint on formly 'props.disabled'

Formly's expressionProperties path-string keys ('props.disabled') aren't
camelCase and the rule has no requiresQuotes exemption; matches the
existing pattern in src/app/features/issue/common-issue-form-stuff.const.ts.

* test(focus-mode): mock pomodoroConfig signal on FocusModeService

The component's initialization effect now reads
focusModeService.pomodoroConfig() in Pomodoro+Preparation mode, but the
three mocked FocusModeService instances in the spec didn't provide it,
causing TypeError in 47 tests on CI (somehow not surfaced locally).

* test(focus-mode): align specs with unified back-to-planning flow

- focus-mode-break.spec: exitBreakToPlanning -> cancelFocusSession
- focus-mode-session-done.spec: drop obsolete hideFocusOverlay assertion
  (cancelFocusSession now handles both clearing tracking and hiding the
  overlay, matching the production component)
- focus-mode-main.spec: storeSpy gains selectSignal returning a signal,
  needed by the displayedTask paused-task fallback

* fix(focus-mode): restore E2E selector hooks on refactored controls

The shared <focus-clock-face> refactor moved pause/complete buttons out
of the clock face into a new .circle-controls row but didn't carry the
class names forward; 23 E2E specs key off them. Also re-add
.task-title-placeholder on the no-task FAB so prep-state checks find it.

- .pause-resume-btn on pause/resume in focus-mode-main + focus-mode-break
- .complete-session-btn on the done_all button in focus-mode-main
- .task-title-placeholder added to .select-task-cta FAB

* fix(focus-mode): aria-label icon buttons; align break E2Es with new flow

- focus-mode-break: pause/resume/skip/reset icon buttons now carry
  [attr.aria-label] in addition to matTooltip — icon ligature alone
  isn't an accessible name and breaks getByRole locators.
- pomodoro-break-timing-bug-6044.spec: skipButton uses getByRole with
  accessible name rather than hasText (mat-icon ligature is "skip_next",
  not "skip break").
- focus-mode-break.spec: "exit break to planning and change timer mode"
  and "Back to Planning should NOT auto-start next session" now match
  the unified back-to-planning flow — overlay closes on click, user
  re-opens focus mode to change settings or verify prep state.

* fix(focus-mode): address PR #7586 review feedback

Maintainer review (johannesjo):
- Debounce the Pomodoro work-duration write so editing it emits one
  synced config op instead of one per keystroke; flush on session start
  so a value typed inside the debounce window is not lost. (A1)
- Replace the local ::ng-deep restyling of the shared input-duration-slider
  with opt-in [bareRing] and [hideHandle] inputs that own the chrome
  overrides in the slider's own styles; the four other consumers keep the
  default look. (A2)
- Add unit tests for the flexible drag math (_setValueFromRotationFlex):
  the A<->B boundary anchoring at 55/60 min and both +/-180 degree wrap
  branches. (A3)
- Delete the orphaned exitBreakToPlanning action, its
  stopTrackingOnExitBreakToPlanning$ effect, reducer case and specs;
  cancelFocusSession already unsets the current task. (B1)
- Drop the unreferenced CONTINUE_TO_NEXT_SESSION and BREAK_RELAX_MSG
  i18n keys. (B2)
- Collapse the duplicated clock-size clamp() into a single
  --clock-face-size-default token. (B4)

Smaller focus-screen fixes (beerkumquatpome):
- Hide the break task title when "pause tracking during breaks" is on. (C7)
- Commit and close the duration editor on Enter. (C9)
- Rename "Back to Planning" to "Exit focus session". (C11)
- Show Flowtime breaks as a neutral "Break". (C13)

Break-circle vertical alignment (C1) is only partially addressed here
(matched the top reservation); exact alignment is a follow-up.

* refactor(focus-mode): share a layout shell across timer screens and auto-start Flowtime breaks

Extract a presentational focus-mode-layout component (4-row content-projection
skeleton: [fmTop]/[fmTask]/[fmClock]/[fmBottom]) shared by the focus-session and
break screens, so both keep a stable clock baseline across the focus<->break and
prep<->in-progress transitions. focus-mode-main and focus-mode-break now consume
the shell instead of each maintaining their own absolute layout.

Replace the Flowtime "break offer" step with an auto-started break, mirroring
Pomodoro:
- Remove the BreakOffer UI state and the offerFlowtimeBreak action/reducer.
- endFlowtimeSession now dispatches completeFocusSession(isManual:false) +
  startBreak (unsetting the task first when tracking-pause-on-break is on), so
  the break starts automatically and the session is logged exactly once via
  logFocusSession$.

Also reorder the bottom controls (Back to Planning leftmost), restore the
BACK_TO_PLANNING label to "Back to Planning", and drop the now-orphaned
FLOWTIME_BREAK_TITLE / START_BREAK i18n keys.

* test(layout): restore document.activeElement after focus-restoration specs

The LayoutService "Focus restoration" tests override document.activeElement
with Object.defineProperty, which shadows the native (inherited) getter with an
own property on document. The afterEach only removed the mock DOM node, so the
override leaked into later specs: once it ran, document.activeElement was frozen
at the mock element and subsequent .focus() calls could no longer move it.

Depending on Karma's spec order this broke the task.service focusTaskById tests
(#7120), which then saw the stale activeElement instead of the element they
focused. Delete the shadowing own property in afterEach to restore native
behavior.

Repro: ng test --include layout.service.spec.ts --include task.service.spec.ts

* refactor(focus-mode): add interactive tracking widget and polish timer-screen layout

- Replace the read-only task-tracking-info with focus-mode-task-tracking
  (vertical time stack + play/pause), wired through the shared layout shell
- Center the task title and floor the task-row height so the clock baseline
  stays aligned across focus <-> break
- Spacing polish: task-title-row and layout gaps to --s2,
  segmented-button-group padding to 0
- Drop redundant safe-area-bottom padding on the action row (the overlay
  already reserves it for the fixed shell)
- Add "Take a moment to relax" break message and a clock-digit edit affordance

* refactor(focus-mode): share timer/break layout, drop dead tracking toggle

- Extract a shared <focus-mode-layout> skeleton and <focus-mode-task-row> used by both the focus session and the break.

- Remove the in-view tracking play/pause toggle (start/stop stays on the global header button); strip focus-mode-task-tracking to read-only and drop RESUME_TRACKING.

- Pin the mode selector out of flow and center the task·clock·bottom group; equal reserved task/bottom rows keep the clock vertically centered, with the selector kept on top.

- Tighten sizing: horizontal selector segments, settings cog matched to the in-session controls, and a fluid clock clamp.
2026-06-12 11:59:56 +02:00
..
archive/encryption-attempts-openvz-incompatible chore(sync-server): archive non-working encryption implementations 2026-01-23 17:36:01 +01:00
docs feat(focus-mode): focus screen UX overhaul (#7586) 2026-06-12 11:59:56 +02:00
helm/supersync feat(supersync): configure server bind host #7301 (#8108) 2026-06-08 12:12:49 +02:00
prisma feat(focus-mode): focus screen UX overhaul (#7586) 2026-06-12 11:59:56 +02:00
public fix(supersync): improve account badge contrast (#8186) 2026-06-09 00:13:33 +02:00
scripts fix(sync): point manual supersync image push at super-productivity org (#7871) 2026-06-01 13:32:06 +02:00
src feat(supersync): configure server bind host #7301 (#8108) 2026-06-08 12:12:49 +02:00
tests fix(sync): make super-sync-server migrations buildable from scratch 2026-06-09 11:36:44 +02:00
tools chore(sync-server): archive non-working encryption implementations 2026-01-23 17:36:01 +01:00
.env.example feat(supersync): configure server bind host #7301 (#8108) 2026-06-08 12:12:49 +02:00
.gitignore fix(sync): preserve own vector clock counter across full-state op resets 2026-04-01 15:41:13 +02:00
Caddyfile docs(supersync): align caddy admin healthcheck note to 127.0.0.1 2026-05-18 16:06:45 +02:00
docker-compose.build.yml fix(supersync): prevent stale deploy image skew 2026-05-15 23:50:41 +02:00
docker-compose.monitoring.yml chore(supersync): harden compose for 4GB VPS production 2026-05-16 12:34:13 +02:00
docker-compose.yml feat(supersync): configure server bind host #7301 (#8108) 2026-06-08 12:12:49 +02:00
DOCKER-MONITORING.md Add active users monitoring command with engagement metrics (#6921) 2026-03-22 23:15:14 +01:00
Dockerfile build: update package.json and fix server build 2026-05-20 16:01:41 +02:00
Dockerfile.test refactor(sync): move vector clocks to sync-core 2026-05-11 15:21:08 +02:00
env.example feat(supersync): configure server bind host #7301 (#8108) 2026-06-08 12:12:49 +02:00
package.json chore(deps): bump @types/supertest from 6.0.3 to 7.2.0 (#7430) 2026-06-09 11:54:04 +02:00
privacy-policy-en.md docs(compliance): document encryption risk and update privacy policy 2026-01-22 13:34:54 +01:00
privacy-policy.md docs(compliance): document encryption risk and update privacy policy 2026-01-22 13:34:54 +01:00
README.md fix(sync): make super-sync-server migrations buildable from scratch 2026-06-09 11:36:44 +02:00
sync-server-architecture-diagrams.md refactor(sync): rename "stale" to "superseded" across sync/operation domain 2026-01-30 16:59:40 +01:00
terms-of-service-en.md docs(super-sync): add 3-month inactive-account deletion clause 2026-04-25 22:36:14 +02:00
terms-of-service.md docs(super-sync): add 3-month inactive-account deletion clause 2026-04-25 22:36:14 +02:00
tsconfig.json fix(sync-server): compile scripts for production Docker image 2025-12-19 15:58:21 +01:00
vitest.config.ts chore(sync): exclude integration test from default run, scope integration config 2026-03-17 13:59:40 +01:00
vitest.integration.config.ts chore(sync): exclude integration test from default run, scope integration config 2026-03-17 13:59:40 +01:00

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:

Architecture

The server uses an Append-Only Log architecture backed by PostgreSQL (via Prisma):

  1. Operations: Clients upload atomic operations (Create, Update, Delete, Move).
  2. Sequence Numbers: The server assigns a strictly increasing server_seq to each operation.
  3. Synchronization: Clients request "all operations since sequence X".
  4. 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

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_STARTUP defaults to false, docker compose pull && docker compose up -d can leave the app running against unapplied migrations. Use ./scripts/deploy.sh for production updates, or ./scripts/deploy.sh --build for 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_init baseline: the migration chain now begins with a 0_init baseline 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, or migrate deploy tries to recreate existing objects and fails (relation "users" already exists, or P3005 The database schema is not empty). This also applies to the unattended deploy paths (the Helm migrate-db initContainer and the Docker RUN_MIGRATIONS_ON_STARTUP=true startup), which fail loudly until baselined.

  • Database with prior Prisma migration history (the pre-0_init migrations are recorded in _prisma_migrations): mark only the baseline as applied.

    npx prisma migrate resolve --applied 0_init
    
  • Database created with prisma db push (no migration history): its schema already matches the latest schema.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")"
    done
    

Fresh databases need none of this — migrate deploy applies 0_init and 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. Get Snapshot

Get the full current state (optimized).

GET /api/sync/snapshot

4. Sync Status

Check pending operations and device status.

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 from
  • limit (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.