Skip to main content
The app ships a generic offline-first data layer. Local SQLite is the UI’s source of truth, so reads and writes never wait on the network. A background sync engine reconciles it with your API: push, then pull, PUT-upsert by a client-minted id, last-write-wins. A new synced entity is a small descriptor plus three REST endpoints, not another hand-written repository. The Notes tab is the worked example, wired end to end (app + API). Keep it as a template or remove it.

Storage per platform

Each app has one database. It opens on first use and runs its migrations then. Its name is VITE_OFFLINE_DB_NAME, falling back to VITE_APP_NAME.
The env templates set VITE_OFFLINE_DB_NAME to shipwide. Rename it when you rebrand, and do it before launch: renaming later orphans every user’s local database, including changes not yet synced.

Files

All paths are under shipwide_app/src/.

How data moves

Two bookkeeping columns on every table drive this:
  • sync_state is 'pending' for a local change not yet pushed and 'synced' once it matches the server.
  • deleted_at is a tombstone. The UI hides the row, and the row stays until the server confirms the DELETE. Then it is removed.

Sync rules

  • When it runs: after the page’s store.load() reads local data, after every local write (debounced, 800 ms by default), on the browser’s online event, when the app returns to the foreground, right after sign-in, and on syncNow().
  • When it doesn’t: while signed out, while offline (navigator.onLine === false), or when the signed-in account doesn’t own the database. The cache is left alone.
  • One run at a time: a sync requested during a run is folded into a single follow-up run.
  • Push first, oldest change first. A live row goes out as PUT /v1/<resource>/:id and is then marked synced. A tombstone goes out as DELETE /v1/<resource>/:id and is then removed locally; a DELETE answered 404 or 410 counts as done. The first transient failure (network error, timeout, 5xx, 429) ends the run: the remaining changes stay pending, nothing is pulled, and the next trigger retries.
  • A rejected change doesn’t block the rest. If the server refuses a change outright (a 4xx other than 401, 408, 426 and 429, such as a validation error or a conflict), the change stays pending and the push moves on to the next one, then pulls as usual. The rejection is reported once per change and session as an Error: console in development, Sentry in production. The change goes out again on later syncs, so an edit or a server fix can still push it.
  • Then pull the full list with GET /v1/<resource>. If the pull fails, nothing is applied, so a network error can never empty the cache.
  • Merge, last-write-wins by updatedAt: a server row overwrites the local row, unless the local row is still pending and at least as new. A tie keeps the local edit. A synced local row that is missing from the server’s list was deleted on another device, so it is removed.

Sign-out and session expiry

The database holds one account’s data at a time. It is tagged with the account that owns it (systemStore.localDataOwnerId), and only Log out wipes it on the spot. A session is rejected when it was revoked (for example, by “sign out other devices”), when its account was deleted, or when its refresh token expired. Its pending changes can’t be pushed: the API refuses that session’s access token too. So they wait on the device until the same account signs back in. The trade-off: after a remote revoke, the data stays on that device until someone logs out there or another account signs in. Until then it sits behind the sign-in screen. Login.vue calls claimLocalData() in runPostLogin(), before it navigates into the app, and then syncAll(). Call both from any sign-in flow you add. If a flow skips it, the sync engine still refuses to push a database another account owns. Sync stays blocked, though, and the pages would show that account’s data.
Offline-first covers staying signed in. Access tokens last 15 minutes. On each navigation between signed-in pages, the route guard refreshes a token that is within 5 minutes of expiry. If the refresh can’t reach the API, the user stays in the app on local data, and the first request once back online refreshes the token. Only a refresh the API refuses sends them to sign in. A refresh token lasts 30 days from the last successful refresh (JWT_REFRESH_LIFETIME_DAYS). After a longer spell offline, the user signs in again once back online, and their pending changes sync then.

Add your own entity

Copy features/notes/ and rename it:
1

Entity

In <name>.entity.ts, write an interface X extends BaseEntity and an OfflineEntitySpec<X>. The spec has table, columns, migrations, rowToEntity, entityToValues and an optional orderBy (defaults to updated_at). columns must include id, created_at and updated_at, and must leave out the two bookkeeping columns. Keep its order fixed; entityToValues returns values in the same order. Then add export const xRepo = createOfflineRepo(spec) and registerOfflineEntity(spec, xRepo.clearAll).The CREATE TABLE step must declare sync_state TEXT NOT NULL DEFAULT 'pending', deleted_at TEXT. Index sync_state too. Ids default to a random UUID. For a deterministic key, such as one row per day or week, set newId so the upsert converges across devices. Build that key from the signed-in user’s id plus the date or week. Ids are primary keys shared by every account on the server, so without the user id two accounts’ rows for the same day get the same id. The second account’s upsert then fails, and its sync stalls on that row.
2

API client

In <name>.api.ts, implement an OfflineApiClient<X> on authenticatedAPI, with list() (GET), put(entity) (PUT :id) and remove(id) (DELETE :id). list() must throw when there is no session. It must never return [].
3

Sync engine

In <name>.sync.ts: export const xSync = createListSync(xRepo, xApi, { label: 'xSync' }).
4

Store

In <name>Store.ts, write a Pinia store over the repo and the sync engine. Copy notesStore.ts, including its registerSyncTarget('<name>', syncAndReload) line: that is what syncs the entity and refreshes its list when the app resumes and after sign-in.
5

Register

Add import '@/features/<name>/<name>.entity'; to services/offline/register.ts.
6

UI

A page, a route with meta: { requiresAuth: true }, optionally a tab-bar or side-menu entry, and i18n. Call store.load() from onIonViewWillEnter, not onMounted: Ionic keeps visited pages alive, so onMounted runs only on the first visit.
7

Backend

A /v1/<name> module in shipwide_api that meets the contract below. Until it exists, the feature works locally and its changes stay pending.

Server contract

All three require a signed-in session. Model the table on notes:
  • The primary key is a text column holding the client’s id, not a server-generated id, so a record keeps the same id on every device.
  • user_id is a foreign key with ON DELETE CASCADE.
  • Run npm run db:generate afterwards.
Never rewrite a stored value (trim, normalize): the client reads the change as a new server edit, and every device keeps re-syncing. If you use newId, make the :id validation accept your key format. The Notes module accepts UUIDs only.

The Notes example

App: features/notes/ holds notes.entity.ts (spec, repo and registration), notes.api.ts, notes.sync.ts, notesStore.ts and NotesPage.vue. The route is /notes (signed-in only), with an entry in the bottom tab bar and in the side menu. Type a note and press Add; swipe a row left to delete it. API: src/modules/notes/ plus the notes table (src/db/schema/notes.ts), which the kit’s migrations create. All routes require sign-in. Only the owning account can read or write a note, and notes are deleted with their account. Against a backend without this module, pushes fail and the notes stay pending, but the tab keeps working locally.

Remove the Notes example

1

App — delete the feature

Delete src/features/notes/. In src/services/offline/register.ts, remove import '@/features/notes/notes.entity';. In src/router/routes.ts, remove the /notes route.
2

App — navigation and i18n

In src/views/core/BottomTabBar.vue, remove the Notes <router-link> and remove documentTextOutline from its icon import. In src/views/core/SideMenu.vue, remove the /notes entry from menuItems, but keep the documentTextOutline import; the Terms item still uses it. Delete src/i18n/locales/*/notes.json and the notes key in each src/i18n/locales/*/navigation.json.
3

API — delete the module

Delete src/modules/notes/, src/db/schema/notes.ts and tests/notes.schemas.test.ts. Then remove three references: the notesRoutes import and its app.register(...) line in src/app.ts; the export * from './notes.js' line in src/db/schema/index.ts; and noteDTO with its NoteRow import in src/shared/serializers.ts.
4

API — drop the table

Run npm run db:generate. It creates a migration that drops the notes table, which applies on the next boot (migrate → seed → serve) or with npm run db:migrate.
5

Verify

Run npm run build and npm test in both parts.
You can keep the layer itself. With no entity registered, nothing opens the database, and the clearAllLocalData() calls on Log out and sign-in do nothing. To remove the layer too, delete src/services/offline/ and src/helpers/uuid.ts (only the layer uses it). Also remove the clearAllLocalData import and call in src/views/pages/_base/Logout.vue, the claimLocalData import and call in src/views/pages/_base/Login.vue, localDataOwnerId in src/stores/core/systemStore.ts (its ref, $reset line, return entry and persist path), public/assets/sql-wasm.wasm, the VITE_OFFLINE_DB_NAME lines in env/, and the @capacitor-community/sqlite, jeep-sqlite and sql.js dependencies. Then run npx cap sync.

Gotchas

  1. jeep-sqlite under Vite: sqlite.ts loads the custom-elements build (jeep-sqlite/dist/components/jeep-sqlite + defineCustomElement()), not jeep-sqlite/loader. The lazy loader never finishes hydrating, so getDb() hangs on web.
  2. The wasm file must match sql.js: public/assets/sql-wasm.wasm must come from the exact sql.js version in package.json (1.11.0). When you upgrade sql.js, copy its dist/sql-wasm.wasm over it. A mismatch breaks only web and desktop, so native testing won’t catch it.
  3. persist() after every write: on web, a write stays in memory until it is saved to IndexedDB. The repo factory does this for you; do the same in any raw SQL you add.
  4. One write at a time: every write shares one connection, and each db.run() opens its own transaction. Two writes that overlap fail on web and desktop (“cannot start a transaction within a transaction”), and the losing write is dropped. The repo factory runs each write method through serializeWrite(). Wrap any raw SQL write you add the same way, including the reads that decide it and its persist(). Don’t call a queued function from inside serializeWrite(): the queue isn’t reentrant, so the call would wait forever. If it happens anyway, the queue reports any write that waited more than 10 seconds for its turn. The report is an Error, so you see it in the console in development and in Sentry in production, instead of a silent hang.
  5. list() throws when signed out: a sign-out that races a sync must not feed an empty list into the merge and wipe the cache.
  6. Migrations only grow, and their numbers are global: never edit a shipped step; add a new one with a higher version. Version numbers are unique across all entities, and a collision throws at startup.
  7. A tie keeps the local edit (>= in the merge). This is intentional; don’t tighten it to >.
  8. Unit tests: npm test (Vitest, TZ=UTC) runs the *.test.ts files next to the code. Tests ship for the offline layer (registry, write queue, cache ownership, sync gate), for token refresh, and for the Pro gate.

Gate it behind Pro

useProGate() (see Billing) caps a free tier in one call: