From 2b8d9dc0dcb0456612502be643cea5960c93b61b Mon Sep 17 00:00:00 2001 From: BirdNET-Go Addon Builder Date: Sat, 17 Oct 2026 12:27:15 +0200 Subject: [PATCH] feat(wger): copy the SQLite database into Postgres when PowerSync is enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling PowerSync means moving wger to Postgres, which until now meant starting from an empty instance. The add-on now does the transfer itself on the first start with PowerSync enabled. Two guarantees, both of them tested: The SQLite database is never written to. It is copied into /data first and every read happens against the copy, so the original keeps its contents, mtime and mode, and no -wal or -shm file appears beside it. Removing PS_DATABASE_URI puts wger straight back onto it. An import only ever happens into a Postgres database holding no user accounts. That is wger's own test for a database that was never set up (wger/tasks.py database_initialised(), where the admin user is created as the last step of the bootstrap), so turning PowerSync off and on again later leaves whatever is in Postgres alone. Mechanism: dumpdata from the copy with PS_DATABASE_URI unset so wger's settings fall back to the DJANGO_DB_* sqlite values, then migrate and loaddata against Postgres. Primary keys are kept, so rows land unchanged and loaddata resets the Postgres sequences for every model it loaded. Content types, permissions, admin log entries and sessions are excluded because migrate rebuilds the first two and the others point at them. It runs in cont-init, before the upstream entrypoint bootstraps, and the wait for Postgres is what stops that bootstrap from racing it: cont-init is synchronous. Afterwards wger's own bootstrap finds a database with users and leaves it alone, and ensure_admin_user() does not touch an existing admin. This commit also carries the second round of Codex review: - Rename the script to 89-powersync-run.sh and delete the /.env loader it gained in the previous round. Settings reach a cont-init script by being injected after its shebang, and 01-config_yaml.sh injects into /etc/cont-init.d/*run* while 00-global_var.sh injects into *.sh — so the name alone makes both mechanisms reach this script the way they already reach 90-run.sh. That removes 35 lines, stops sourcing /.env six times as root before the enable check, and fixes the loader's own flaw: an explicitly empty value could not override the environment. - Copy only JWT_PUBLIC_KEY and JWT_PRIVATE_KEY lines out of the persisted key file into /.env. jwt_file_is_complete tolerates extra lines, and the entrypoint evaluates every assignment it finds in /.env. - Refuse a SITE_URL with an empty host such as http://:8080, which the previous patterns accepted while the comment claimed otherwise. - Drop the wal_errors temp file and let that command's stderr reach the add-on log directly. - Log the wait for wger every five minutes rather than every minute. - Document the missing ALTER ROLE wger REPLICATION, say that REPLICATION belongs to the PS_DATABASE_URI role rather than powersync_storage, note that the GRANT needs a role allowed to hand out that membership, and stop overstating that nothing at all changes for users who leave the feature off: a stale /ps/ route is cleared and one unused nginx map is parsed. Co-Authored-By: Claude Opus 5 --- wger/CHANGELOG.md | 4 +- wger/Dockerfile | 2 +- wger/README.md | 58 +++-- .../{89-powersync.sh => 89-powersync-run.sh} | 211 ++++++++++++++---- wger/rootfs/etc/nginx/servers/nginx.conf | 2 +- 5 files changed, 212 insertions(+), 65 deletions(-) rename wger/rootfs/etc/cont-init.d/{89-powersync.sh => 89-powersync-run.sh} (62%) diff --git a/wger/CHANGELOG.md b/wger/CHANGELOG.md index e680a323a8..a5a4566d02 100644 --- a/wger/CHANGELOG.md +++ b/wger/CHANGELOG.md @@ -2,9 +2,9 @@ ## 2.8.0-dev.1.2 (2026-10-17) - Add optional PowerSync support, so the official wger mobile app (2.0.3 and newer) can sync offline (#2950). Set the new `POWERSYNC_ENABLED` option to start a PowerSync service inside the add-on, reached at `/ps/` through the add-on's own nginx, exactly as upstream's docker deployment serves it. -- ⚠ PowerSync cannot replicate from SQLite. Enabling it therefore means pointing wger at a Postgres database (for example the Postgres 17 add-on) with `PS_DATABASE_URI`, and **the add-on provides no migration for an existing SQLite database — the Postgres database starts empty, and your current workouts, weight and nutrition data and your user accounts do not follow.** The SQLite file is left untouched, so removing the setting puts you back on it. Read the PowerSync section of the add-on documentation before switching. +- ⚠ PowerSync cannot replicate from SQLite, so enabling it means pointing wger at a Postgres database (for example the Postgres 17 add-on) with `PS_DATABASE_URI`. **The add-on copies your SQLite data across for you on the first start.** It never writes to the SQLite file — it reads a copy — and it only ever imports into a Postgres database with no user accounts, so turning PowerSync off and on again later never overwrites what is already there. The two databases do not stay in step afterwards: whichever one wger is pointed at is the live one. Read the PowerSync section of the add-on documentation before switching. - PowerSync settings can be supplied either through the add-on's `env_vars` option or through the `CONFIG_LOCATION` config.yaml file; both are read. -- With `POWERSYNC_ENABLED` unset, nothing changes: the option defaults to off, the nginx configuration is unchanged, no new process runs and the SQLite database stays the default. The PowerSync files ship in the image either way, which makes it about 90 MB larger to download. +- With `POWERSYNC_ENABLED` unset, the option defaults to off, no new process runs, no database is touched and the SQLite database stays the default. Two things are different even then, both harmless: a `/ps/` route left behind by an earlier enabled run is removed at startup, and nginx parses one additional `map` that nothing uses. The PowerSync files ship in the image either way, which makes it about 90 MB larger to download. ## 2.8.0-dev.1 (2026-10-10) - Update to latest version from wger/server diff --git a/wger/Dockerfile b/wger/Dockerfile index f4a596a2e6..e9d74141c1 100644 --- a/wger/Dockerfile +++ b/wger/Dockerfile @@ -46,7 +46,7 @@ ENV SYNC_EXERCISES_ON_STARTUP=True \ DJANGO_MEDIA_ROOT="/data/media" \ DJANGO_STATIC_ROOT="/data/static" -# PowerSync defaults. Inert unless 89-powersync.sh starts the service: PS_PORT and +# PowerSync defaults. Inert unless 89-powersync-run.sh starts the service: PS_PORT and # PS_JWKS_URL are read only by PowerSync itself, and the wger settings already default # PS_STORAGE_PG_URI, which the user has to override to a reachable database anyway. ENV PS_PORT=8080 \ diff --git a/wger/README.md b/wger/README.md index dae64ab806..c0e406df5a 100644 --- a/wger/README.md +++ b/wger/README.md @@ -63,22 +63,37 @@ The official wger mobile app (2.0.3 and newer) syncs offline through a `/ps/`. The add-on ships that service and can run it alongside wger, off by default. Turn it on with the `POWERSYNC_ENABLED` option. -### ⚠ Read this first: enabling PowerSync means leaving SQLite behind +### Switching to PostgreSQL, and switching back PowerSync cannot replicate from SQLite, which is what this add-on uses by default, and -wger's integration with it is built around PostgreSQL. So enabling PowerSync means -pointing wger at a PostgreSQL database. +wger's integration with it is built around PostgreSQL. Enabling PowerSync therefore means +pointing wger at a PostgreSQL database — and the add-on moves your data across for you. -**The add-on provides no migration from its SQLite database to PostgreSQL.** When you -switch, wger builds a fresh, empty database: your workouts, weight entries, nutrition -logs **and your user accounts** do not come with it — you will create and log in to a new -account. The SQLite file is left untouched at `/data/database.sqlite` in the add-on's data -directory, and removing `PS_DATABASE_URI` again puts you back on it exactly as it was. +The first time you start it with PowerSync enabled, the add-on copies the contents of its +SQLite database into the PostgreSQL one. Two things are guaranteed about that: -Moving the data across is possible by hand — Django's `dumpdata`/`loaddata` or a -SQLite-to-PostgreSQL conversion tool — but none of that is automated or supported here. -Do not switch an instance you care about without doing that first, or without accepting -that you are starting over. +- **The SQLite database is never written to.** It is copied first, and everything is read + from the copy, so the original is left byte for byte as it was. Removing + `PS_DATABASE_URI` again puts wger straight back onto it — remove it from *everywhere* + you set it, both the `env_vars` option and the `CONFIG_LOCATION` file, since either + one takes precedence over the SQLite default. +- **An import only ever happens into an empty PostgreSQL database.** "Empty" here means + one with no user accounts at all, which is wger's own test for a database that was + never set up. So if you turn PowerSync off and on again later, whatever is already in + PostgreSQL is left alone and nothing is overwritten. + +What the import does not do is keep the two databases in step. After the switch, +PostgreSQL is the live database and the SQLite file is a frozen copy of the moment you +switched. If you go back to SQLite, anything you did in the meantime stays in PostgreSQL, +and vice versa. You will also be logged out of the web interface once, because sessions +are deliberately not carried over. + +If the import cannot finish — the database is unreachable, or the copy or the load fails +— the add-on says so in the log, does not start PowerSync, and leaves the SQLite database +alone. One case is worth knowing about: if PostgreSQL never becomes reachable at all, your +data is not imported, and wger will go on to set the empty PostgreSQL database up as a +brand new instance. Fix the connection and restart the add-on before using wger if you +see that in the log. ### What you need @@ -103,7 +118,14 @@ that you are starting over. GRANT CREATE ON DATABASE wger TO wger; ``` - On PostgreSQL 16 and later — which includes the Postgres 17 add-on — that is not + The same role — the one in `PS_DATABASE_URI`, not `powersync_storage` — also needs + `REPLICATION`, which is how PowerSync reads the logical WAL: + + ```sql + ALTER ROLE wger REPLICATION; + ``` + + On PostgreSQL 16 and later — which includes the Postgres 17 add-on — that is still not quite enough. Creating the schema runs `CREATE SCHEMA ... AUTHORIZATION powersync_storage`, and [the documentation](https://www.postgresql.org/docs/17/sql-createschema.html) requires @@ -116,15 +138,17 @@ that you are starting over. ALTER ROLE wger SET createrole_self_grant = 'set, inherit'; ``` - If the storage role already exists from an earlier attempt, grant it directly instead: + If the storage role already exists from an earlier attempt, grant it directly instead + — this one has to be run by a role allowed to hand out that membership, normally the + superuser: ```sql GRANT powersync_storage TO wger WITH ADMIN OPTION, SET TRUE; ``` - The role also needs `REPLICATION` for PowerSync to read the logical WAL. The Postgres - superuser has all of this, which is why pointing at it works and is the quickest way - to get going, but a role with just these grants is the better choice. + The PostgreSQL superuser has all of this already, which is why pointing at it works + and is the quickest way to get going, but a role with just these grants is the better + choice. 3. The URL your phone uses to reach wger, in `SITE_URL`. The app is handed `/ps/` as its sync endpoint, so a LAN-only address will not work from outside your network. diff --git a/wger/rootfs/etc/cont-init.d/89-powersync.sh b/wger/rootfs/etc/cont-init.d/89-powersync-run.sh similarity index 62% rename from wger/rootfs/etc/cont-init.d/89-powersync.sh rename to wger/rootfs/etc/cont-init.d/89-powersync-run.sh index ac8790f140..68e0e6e289 100644 --- a/wger/rootfs/etc/cont-init.d/89-powersync.sh +++ b/wger/rootfs/etc/cont-init.d/89-powersync-run.sh @@ -9,6 +9,13 @@ # # Runs at 89 so the nginx snippet is in place before 90-run.sh starts nginx, and so # generated JWT keys land in /.env before 90-run.sh folds that file into /data/env.sh. +# +# The "-run" in the name is load-bearing. Settings reach a cont-init script by being +# injected after its shebang, and the two mechanisms use different globs: +# 00-global_var.sh (add-on options and env_vars) injects into /etc/cont-init.d/*.sh, +# while 01-config_yaml.sh (the CONFIG_LOCATION file this add-on documents for extra +# variables) injects into /etc/cont-init.d/*run* . Matching both is what lets this +# script see the same settings 90-run.sh hands to Django. set -u @@ -22,7 +29,7 @@ ENV_FILE=/.env # before gunicorn answers, which upstream and the add-on README both put in the # tens of minutes. The barrier has to outlast that, not a typical restart. DJANGO_WAIT_SECONDS=1800 -DJANGO_WAIT_LOG_EVERY=60 +DJANGO_WAIT_LOG_EVERY=300 # ha_entrypoint.sh's own service supervisor is unreachable here: it only runs when # ha_entrypoint.sh is PID 1, and in this add-on it is a child of the upstream @@ -34,37 +41,12 @@ DJANGO_WAIT_LOG_EVERY=60 RESTART_DELAY_MIN=5 RESTART_DELAY_MAX=60 -######################### -# Settings from the config.yaml file -######################### - -# Two mechanisms feed settings in, and only one of them reaches this script on its own. -# 00-global_var.sh injects add-on options and env_vars into every /etc/cont-init.d/*.sh, -# but 01-config_yaml.sh - the CONFIG_LOCATION file this add-on documents for extra -# variables - only writes to /.env and injects into /etc/cont-init.d/*run* , which this -# script is deliberately not named. So read /.env, which both of them write. -# -# /.env wins over the environment on purpose: that is the precedence 90-run.sh produces -# for Django further down, and this script has to validate the values Django will use. -# It is sourced in a subshell because the quoting in there is the shell's own, and the -# same content is already sourced by the upstream entrypoint by way of /data/env.sh. -env_file_value() { - local name="$1" - [ -f "$ENV_FILE" ] || return 0 - ( - set +u - # shellcheck source=/dev/null - . "$ENV_FILE" > /dev/null 2>&1 || true - printf '%s' "${!name}" - ) -} - -for _setting in POWERSYNC_ENABLED PS_DATABASE_URI PS_STORAGE_PG_URI SITE_URL \ - JWT_PUBLIC_KEY JWT_PRIVATE_KEY; do - _value="$(env_file_value "$_setting")" - [ -n "$_value" ] && export "$_setting=$_value" -done -unset _setting _value +# The one-off SQLite import runs here in cont-init, which is before the upstream +# entrypoint migrates and bootstraps, so waiting here is what stops that bootstrap from +# racing the import: cont-init is synchronous. The Postgres add-on starts before regular +# add-ons, so this is a margin rather than an expected wait. +POSTGRES_WAIT_SECONDS=300 +IMPORT_DIR=/data/powersync/import # The Supervisor destroys and recreates the container on every add-on start, so this # file is normally absent already. An out-of-band restart (docker restart, Portainer) @@ -108,6 +90,19 @@ django_manage() { (cd /home/wger/src && sudo -u wger -E python3 manage.py "$@") } +# The same, pointed at a SQLite file instead. PS_DATABASE_URI has to go: wger's +# settings/main.py checks it before the DJANGO_DB_* block, so it would win. +sqlite_manage() { + local database="$1" + shift + ( + cd /home/wger/src && sudo -u wger -E env -u PS_DATABASE_URI \ + DJANGO_DB_ENGINE=django.db.backends.sqlite3 \ + DJANGO_DB_DATABASE="$database" \ + python3 manage.py "$@" + ) +} + # Both halves present, one line each. Checked on the way out of generation and again on # reuse, since the file is at a path a user can reach and edit. jwt_file_is_complete() { @@ -141,7 +136,7 @@ prepare() { "") refuse "SITE_URL is not set. The app is handed /ps/ as the sync endpoint, so it has to be the URL your phone reaches wger on." || return 1 ;; http://localhost | http://localhost:* | https://localhost | https://localhost:*) refuse "SITE_URL is still the default ${SITE_URL}, which no phone can reach. Set it to the URL you open wger on." || return 1 ;; - http:// | https://) refuse "SITE_URL has no host: ${SITE_URL}" || return 1 ;; + http:// | https:// | http://:* | https://:*) refuse "SITE_URL has no host: ${SITE_URL}" || return 1 ;; http://*/* | https://*/*) refuse "SITE_URL must not contain a path: ${SITE_URL}" || return 1 ;; # wger appends "//" to SITE_URL, so a query or fragment here would swallow # the /ps/ it is meant to produce. @@ -194,20 +189,150 @@ prepare() { # 90-run.sh keeps the last assignment it reads out of /.env. if [ "$use_persisted_keys" = true ]; then # 90-run.sh reads /.env back and re-exports every KEY=VALUE line into - # /data/env.sh, which the upstream entrypoint sources before Django starts. + # /data/env.sh, which the upstream entrypoint sources before Django starts, so + # only the two lines this script put there may travel: anything else in an + # edited key file would become an assignment the entrypoint evaluates. # The values are the URL-safe base64 that generate-jwt-keys prints, so they # contain nothing that needs escaping inside double quotes. while IFS= read -r line; do - [ -n "$line" ] || continue + case "$line" in + JWT_PUBLIC_KEY=* | JWT_PRIVATE_KEY=*) ;; + *) continue ;; + esac printf '%s="%s"\n' "${line%%=*}" "${line#*=}" >> "$ENV_FILE" done < "$JWT_FILE" fi } +######################### +# One-off SQLite import +######################### + +# Mirrors wger's own database_initialised() in wger/tasks.py: a database that cannot be +# reached is an error rather than an empty database, and "no users at all" is upstream's +# definition of a database that was never set up, because the admin user is created as +# the last step of its bootstrap. Prints unreachable, fresh (reachable, no tables yet), +# empty (tables but no users) or populated. +target_database_state() { + django_manage shell 2> /dev/null << 'PROBE' | tail -n 1 +from django.db import DatabaseError, connection + +try: + connection.ensure_connection() +except Exception: + print('unreachable') +else: + from django.contrib.auth.models import User + + try: + print('populated' if User.objects.exists() else 'empty') + except DatabaseError: + print('fresh') +PROBE +} + +# Copy the contents of the add-on's SQLite database into the configured Postgres one, +# once, the first time PowerSync is enabled. +# +# The SQLite file is never opened by this: it is copied first and every read happens +# against the copy, so disabling PowerSync again (by removing PS_DATABASE_URI) returns +# to the original database exactly as it was. +# +# It also never overwrites: an import happens only into a Postgres database that holds +# no users, so re-enabling the feature later leaves whatever is already in Postgres +# alone. The two databases do not stay in sync afterwards - whichever one wger is +# pointed at is the live one. +import_sqlite_data() { + local sqlite_db="${DJANGO_DB_DATABASE:-/data/database.sqlite}" + local waited=0 state="" + local dump="$IMPORT_DIR/dump.json" copy="$IMPORT_DIR/database.sqlite" + + if [ ! -s "$sqlite_db" ]; then + bashio::log.info "No SQLite database at $sqlite_db, nothing to import" + return 0 + fi + + bashio::log.info "Checking whether the Postgres database already holds wger data" + while true; do + state="$(target_database_state)" + [ "$state" != "unreachable" ] && break + if [ "$waited" -ge "$POSTGRES_WAIT_SECONDS" ]; then + bashio::log.error "PowerSync: the Postgres database is still unreachable after ${POSTGRES_WAIT_SECONDS}s" + bashio::log.error "PowerSync: your SQLite data has NOT been imported. Fix the connection and restart the add-on BEFORE using wger, or the empty Postgres database will be set up as a new instance." + bashio::log.error "PowerSync: the SQLite database itself is untouched at $sqlite_db" + return 1 + fi + [ "$waited" -eq 0 ] && bashio::log.info "PowerSync: waiting for the Postgres database" + sleep 5 + waited=$((waited + 5)) + done + + if [ "$state" = "populated" ]; then + bashio::log.info "The Postgres database already holds wger data; leaving it as it is and importing nothing" + return 0 + fi + + bashio::log.info "Importing the SQLite database into Postgres (one-off, $sqlite_db is only read)" + # The dump holds every row of the database, password hashes included, and the copy + # is a second copy of the same file. Both are removed again below. + rm -rf "$IMPORT_DIR" + mkdir -p "$IMPORT_DIR" + chmod 0700 "$IMPORT_DIR" + chown wger "$IMPORT_DIR" || bashio::log.warning "Unable to give $IMPORT_DIR to the wger user" + + if ! cp -p "$sqlite_db" "$copy"; then + bashio::log.error "PowerSync: could not copy the SQLite database, skipping the import" + rm -rf "$IMPORT_DIR" + return 1 + fi + chown wger "$copy" || true + + # Dumped from the copy, with the tables Django rebuilds for itself left out: content + # types and permissions are recreated by migrate, and admin log entries and sessions + # point at them. --natural-foreign keeps those references resolvable; primary keys + # are deliberately kept as they are so the rows land unchanged. + if ! sqlite_manage "$copy" dumpdata \ + --natural-foreign \ + --exclude contenttypes \ + --exclude auth.Permission \ + --exclude admin.logentry \ + --exclude sessions.session \ + --output "$dump"; then + bashio::log.error "PowerSync: reading the SQLite database failed, nothing was written to Postgres" + bashio::log.error "PowerSync: the SQLite database itself is untouched at $sqlite_db" + rm -rf "$IMPORT_DIR" + return 1 + fi + + if ! django_manage migrate --no-input; then + bashio::log.error "PowerSync: creating the schema in Postgres failed, the import is incomplete" + rm -rf "$IMPORT_DIR" + return 1 + fi + + # loaddata resets the Postgres sequences for every model it loaded, so the next + # insert does not collide with an imported primary key. + if ! django_manage loaddata "$dump"; then + bashio::log.error "PowerSync: writing the data into Postgres failed. The Postgres database is now half-populated: empty it before trying again." + bashio::log.error "PowerSync: the SQLite database itself is untouched at $sqlite_db" + rm -rf "$IMPORT_DIR" + return 1 + fi + + rm -rf "$IMPORT_DIR" + bashio::log.info "Import finished. wger is now running on Postgres; $sqlite_db is left behind unchanged in case you want to go back" + return 0 +} + if ! prepare; then exit 0 fi +if ! import_sqlite_data; then + bashio::log.error "PowerSync not started: the SQLite import did not complete, see above." + exit 0 +fi + ######################### # nginx route ######################### @@ -216,7 +341,7 @@ fi # here, so with the feature off the glob matches nothing. mkdir -p "$NGINX_PS_DIR" cat > "$NGINX_PS_DIR/location.conf" << 'EOF' -# Generated by 89-powersync.sh. Mirrors wger-project/docker config/nginx.conf. +# Generated by 89-powersync-run.sh. Mirrors wger-project/docker config/nginx.conf. location /ps/ { proxy_pass http://127.0.0.1:8080/; @@ -268,20 +393,16 @@ start_powersync() { # Postgres only writes the logical WAL that PowerSync reads when wal_level is # logical, and the default in both Postgres add-ons is replica. Without this the # feature looks enabled and silently never syncs. - wal_errors="$(mktemp)" + # stderr is left alone on purpose: when this fails it is usually a database that + # cannot be reached rather than the setting, and the reason belongs in the log. wal_level="$(django_manage shell -c \ "from django.db import connection; c = connection.cursor(); c.execute('SHOW wal_level'); print(c.fetchone()[0])" \ - 2> "$wal_errors" | tail -n 1)" + | tail -n 1)" if [ "$wal_level" != "logical" ]; then bashio::log.error "PowerSync: the database reports wal_level=${wal_level:-unknown}, logical replication needs 'logical'" bashio::log.error "PowerSync: add \"wal_level = logical\" to the Postgres add-on configuration and restart it" - # Most often this is not the setting at all but a database that cannot be - # reached, and then the reason is only in here. - [ -s "$wal_errors" ] && bashio::log.error "PowerSync: $(tail -n 3 "$wal_errors")" - rm -f "$wal_errors" return 1 fi - rm -f "$wal_errors" # Idempotent, and creates the storage role with exactly the credentials in # PS_STORAGE_PG_URI, so whatever the user put there is what gets created. @@ -300,7 +421,9 @@ start_powersync() { sudo -u wger -E "$POWERSYNC_DIR/bin/node" service/lib/entry.js start -r unified || rc=$? if [ "$rc" -eq 0 ]; then # Same rule ha_entrypoint.sh applies to its own services: a clean exit - # is not a crash, and restarting one fights the add-on shutting down. + # is not a crash. Nothing signals this process at shutdown - it is + # orphaned when cont-init returns, exactly like the nginx 90-run.sh + # starts - so the container runtime is what finally stops it. bashio::log.info "PowerSync exited cleanly, not restarting" break fi diff --git a/wger/rootfs/etc/nginx/servers/nginx.conf b/wger/rootfs/etc/nginx/servers/nginx.conf index 6adaeccc8e..6e2bdba9e8 100644 --- a/wger/rootfs/etc/nginx/servers/nginx.conf +++ b/wger/rootfs/etc/nginx/servers/nginx.conf @@ -4,7 +4,7 @@ server { root /home/wger; # /ps/ -> the bundled PowerSync service, used by the mobile app for offline sync. - # Written by 89-powersync.sh only when the POWERSYNC_ENABLED option is set, so with + # Written by 89-powersync-run.sh only when the POWERSYNC_ENABLED option is set, so with # the feature off this glob matches nothing and the served config is unchanged. include /etc/nginx/ps/*.conf;