This document consolidates documentation for:
- PowerSync: multi-device sync (synced tables, local dev, adding tables)
- Account: deletion flow and how other devices reset
- Devices: registration, list, revoke, and how a revoked device resets
PowerSync provides offline-first sync between the backend (PostgreSQL) and clients (SQLite). Data is scoped by user_id from the JWT. The backend issues PowerSync JWTs and can apply client uploads (PUT/PATCH/DELETE) to Postgres. Production uses PowerSync Cloud; local development uses the Docker stack in powersync-service/.
For the sync data transformation middleware and custom SharedWorker (E2E encryption pipeline), see docs/powersync-sync-middleware.md.
- Every synced table must have a
user_idcolumn (sync rules and backend scope byuser_id). - Define the table in both:
- Frontend: src/db/tables.ts (SQLite)
- Backend: backend/src/db/powersync-schema.ts (PostgreSQL)
- Backend schema uses minimal indexes: Only primary keys and
user_idindexes (see Indexes and Foreign Keys below).
Defined in shared/powersync-tables.ts:
settings, chat_threads, chat_messages, tasks, models, mcp_servers, prompts, triggers, modes, model_profiles, devices.
Backend (PostgreSQL) uses a minimal index strategy:
- ✅ Primary keys (required)
- ✅ Single
user_idindex on every table (required for PowerSync sync rules) - ❌ No composite foreign key constraints
- ❌ No active indexes (
WHERE deletedAt IS NULL) - ❌ No foreign key indexes
Rationale: The backend is primarily a sync server, not a query engine. Complex queries and JOINs happen on the frontend (SQLite). With E2E encryption planned, backend indexes on encrypted data would be useless. Minimal indexes reduce storage overhead and improve write performance during sync operations.
Frontend (SQLite) can use any indexes needed for local query optimization since queries happen there.
See docs/composite-primary-keys-and-default-data.md for detailed explanation.
- Create the table in both
src/db/tables.tsandbackend/src/db/powersync-schema.ts(includeuser_id). - Backend schema: Add only a
user_idindex:index('idx_[table]_user_id').on(table.userId). Do not add composite foreign keys or other indexes (see above). - Register in src/db/powersync/schema.ts (
drizzleSchema). - Add the table name and query keys in shared/powersync-tables.ts (
POWERSYNC_TABLE_NAMESandpowersyncTableToQueryKeys). - Update powersync-service/config/config.yaml: add a line under
sync_rules.content→bucket_definitions.user_data.data(e.g.- SELECT * FROM my_table WHERE my_table.user_id = bucket.user_id). - Run migrations for frontend and backend as needed.
Split the work into two PRs to avoid sync rule mismatches:
-
PR 1 – Backend schemas and migrations
- Backend: table in
backend/src/db/powersync-schema.ts, migration,shared/powersync-tables.ts,config.yamlsync rules. - Merge this PR first.
- After deploy finishes, update sync rules in the PowerSync Cloud dashboard (production uses PowerSync Cloud; local uses
powersync-serviceconfig).
- Backend: table in
-
PR 2 – Frontend and remaining changes
- Frontend: table in
src/db/tables.ts,src/db/powersync/schema.ts, and any UI/feature code. - Merge after PR 1 is deployed and PowerSync rules are updated.
- Frontend: table in
See powersync-service/README.md for full steps. Summary:
- From the repo root:
make up(or frompowersync-service/:docker compose up -d) - PowerSync API: http://localhost:8080
- Postgres: localhost:5433 (use this for the backend so PowerSync and app share one database)
- Backend
.env: setDATABASE_DRIVER=postgres,DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres, and PowerSync vars (see below) - Sync rules in
powersync-service/config/config.yamlmust match backend tables; when you add/change tables, update that file andVALID_TABLESinbackend/src/api/powersync.ts(which usesPOWERSYNC_TABLE_NAMESfrom shared).
POWERSYNC_URL=http://localhost:8080
POWERSYNC_JWT_SECRET=powersync-dev-secret-change-in-production
POWERSYNC_JWT_KID=powersync-dev
POWERSYNC_TOKEN_EXPIRY_SECONDS=3600The local config/config.yaml uses HS256 with the same secret (base64) and kid so backend-issued tokens are accepted.
- Where: Settings > Preferences → “Delete my account” (with confirmation).
- Request: Frontend calls
DELETE /v1/accountwith the current auth token. - Backend: Deletes the user and all related data (settings, chats, models, devices, etc.).
- Other devices: When PowerSync refreshes the token, the backend returns 410 Gone with
code: 'ACCOUNT_DELETED'. The app treats this as credentials invalid and runs the reset flow (see section 7).
- Backend:
devicestable:id,user_id,name,status(APPROVAL_PENDING|TRUSTED|REVOKED),public_key,mlkem_public_key,last_seen,created_at,revoked_at. Synced via PowerSync. - Frontend: Same schema in the local DB; used for Settings > Devices and for “current device revoked?” checks.
- See e2e-encryption.md for how
statusandpublic_keyare used in the encryption setup and device approval flows.
- Where: Settings > Devices.
- Data: Devices from the local DB (synced
devicestable) viagetAllDevices()and React Query key['devices']. - UI: Name, last seen, “This device” for current device, “Revoked” when
revoked_atis set. “Revoke” only for other, non-revoked devices.
- User chooses “Revoke” on another device (with confirmation). Frontend calls
POST /v1/account/devices/:id/revoke. - Backend runs a transaction: deletes the device’s envelope from the
envelopestable, then setsstatustoREVOKEDandrevoked_aton the device row. The wrapped CK is permanently removed, preventing future CK recovery even if the device’s private key is compromised. PowerSync syncs the updateddevicestable. - On the revoked device:
- Immediate: The app watches the current device’s row via React Query (
getDevice(deviceId)). When the synced row hasstatus === ‘REVOKED’orrevoked_atset, the app runs the reset flow. - On token refresh: Backend returns 403 Forbidden with
code: ‘DEVICE_DISCONNECTED’; the connector dispatches credentials invalid and the app resets.
- Immediate: The app watches the current device’s row via React Query (
- Auth token: In
localStorage(fixed key). Cleared on reset vialocalStorage.clear(). - Device id: In
localStorage. Sent asX-Device-ID(and optionalX-Device-Name) on PowerSync token requests so the backend can register/update the device and enforce revoke.
- With
X-Device-ID:- Backend checks the
devicesrow for that id. Ifstatus === 'REVOKED'orrevoked_atis set → 403 with{ code: 'DEVICE_DISCONNECTED' }, no token. - Otherwise: issues a PowerSync JWT and upserts the device (id, user_id, name, last_seen, created_at).
- Backend checks the
- Bearer token only (e.g. credential refresh):
- If the user no longer exists (account deleted) → 410 Gone with
{ code: 'ACCOUNT_DELETED' }. - Otherwise may return 401 (invalid/expired token).
- If the user no longer exists (account deleted) → 410 Gone with
- Requires authenticated user and
X-Device-IDheader. - Same device validation as token: if device is revoked → 403 with
{ code: 'DEVICE_DISCONNECTED' }. IfX-Device-IDis missing → 400 with{ code: 'DEVICE_ID_REQUIRED' }. - Only non-revoked devices can upload data.
Summary for client:
- 410 → account deleted (reset).
- 403 with
DEVICE_DISCONNECTED→ this device revoked (reset). - 409 with
DEVICE_ID_TAKEN→ device id already registered to another user; reset to get a fresh device id. - 401 → generic auth failure.
- Requires authenticated user (session).
- Runs in a transaction: deletes the device's envelope, then sets
statustoREVOKEDandrevoked_atfor the device that belongs to the current user. - 204 on success (idempotent for already-revoked devices).
The following endpoints handle encryption setup, device approval, and key recovery. See e2e-encryption.md for full documentation.
POST /devices— register device with public key (encryption setup)POST /devices/:deviceId/envelope— store wrapped content keyGET /devices/me/envelope— fetch own wrapped content keyGET /encryption/canary— fetch canary for recovery key verification
When the app should reset (account deleted or device revoked), it runs a single flow:
setSyncEnabled(false)– disconnect from PowerSync.localStorage.clear()– remove auth token and device id.resetAppDir()– clear the app directory (DB and related files).window.location.reload()– reload to a clean, signed-out state.
Triggered in two ways:
- Event
powersyncCredentialsInvalid
Dispatched when the token request returns 410 or 403 with bodycode: 'DEVICE_DISCONNECTED'. - Devices table (current device revoked)
usePowerSyncCredentialsInvalidListeneruses React QuerygetDevice(deviceId)and key['devices', deviceId]. When the synceddevicesrow hasrevoked_atset for the current device, the hook runs the same reset flow (immediate, without waiting for next token refresh).
After revoke, the Settings > Devices list is updated by invalidating ['devices'] so the list reflects the new state after sync.
| Action | Where | Backend / sync behavior | Other device behavior |
|---|---|---|---|
| Delete account | Preferences | User and data deleted; 410 on token refresh | Reset when 410 received or when sync reflects deletion |
| Revoke device | Devices | Set revoked_at; 403 on that device’s refresh |
Revoked device resets when it sees revoked_at (useQuery) or gets 403 on refresh |
Both paths use the same reset: disable sync, clear localStorage, reset app dir, reload.