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.
Files
All paths are undershipwide_app/src/.
How data moves
sync_stateis'pending'for a local change not yet pushed and'synced'once it matches the server.deleted_atis 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’sonlineevent, when the app returns to the foreground, right after sign-in, and onsyncNow(). - 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>/:idand is then marked synced. A tombstone goes out asDELETE /v1/<resource>/:idand 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
Copyfeatures/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
textcolumn holding the client’s id, not a server-generated id, so a record keeps the same id on every device. user_idis a foreign key withON DELETE CASCADE.- Run
npm run db:generateafterwards.
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.Gotchas
- jeep-sqlite under Vite:
sqlite.tsloads the custom-elements build (jeep-sqlite/dist/components/jeep-sqlite+defineCustomElement()), notjeep-sqlite/loader. The lazy loader never finishes hydrating, sogetDb()hangs on web. - The wasm file must match
sql.js:public/assets/sql-wasm.wasmmust come from the exactsql.jsversion inpackage.json(1.11.0). When you upgradesql.js, copy itsdist/sql-wasm.wasmover it. A mismatch breaks only web and desktop, so native testing won’t catch it. 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.- 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 throughserializeWrite(). Wrap any raw SQL write you add the same way, including the reads that decide it and itspersist(). Don’t call a queued function from insideserializeWrite(): 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 anError, so you see it in the console in development and in Sentry in production, instead of a silent hang. 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.- 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.
- A tie keeps the local edit (
>=in the merge). This is intentional; don’t tighten it to>. - Unit tests:
npm test(Vitest,TZ=UTC) runs the*.test.tsfiles 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: