@aventoapp/framework
Server and web modules for multi-tenant SaaS apps built on Avento.
npm i @aventoapp/framework
| Subpath | What it holds |
|---|---|
@aventoapp/framework/server | Express middleware, database clients, workflow engine, services, and the server half of the offline kit (applyFieldLWW) |
@aventoapp/framework/web | React web modules |
@aventoapp/framework/shared | Code shared by server and web |
@aventoapp/framework/offline | The offline sync kit: read cache, write outbox, sync engine. No React, no DOM required |
@aventoapp/framework/native | React Native modules |
Offline sync kit
The kit lets a web or mobile client keep working with no signal. Reads come from the network when online and from the last saved copy when not. Edits are applied to the cached copy at once, queued, and sent when the device is back online. The server settles conflicts per field: the newest edit of each field wins, by client timestamp, so two people editing different fields of one record never overwrite each other.
There are two halves:
- Client (
/offline):createOfflineKitplus its parts (createIdb,createClock). - Server (
/server):applyFieldLWW, which writes only the fields whose incoming timestamp is at least as new as the stored one.
1. Create the kit
import { createOfflineKit, createIdb, createClock } from '@aventoapp/framework/offline';
const idb = createIdb({ name: 'my-app-offline', indexedDB: window.indexedDB });
const clock = createClock();
const kit = createOfflineKit({
idb,
clock,
isOnline: navigator.onLine,
request, // your transport, below
});
createIdb({ name, indexedDB }) opens one IndexedDB database with three
object stores, cache, outbox and meta. indexedDB is injected, so tests
pass fake-indexeddb and the browser passes window.indexedDB. Nothing in the
kit reads a browser global on its own.
createClock({ nowFn }) timestamps every edit. If you can read the server's
time (a Date response header, say), call
clock.setOffsetFromServer(serverEpochMs) so a device with a wrong clock does
not win or lose every conflict. clock.getOffsetMs() returns the current
offset.
The request transport
The kit never calls fetch itself. Every network call goes through the
request function you pass, which must resolve (never throw) to
{ ok, body }:
async function request({ url, method, body, headers, isBlob, idemKey, meta }) {
const res = await fetch(url, {
method,
headers: isBlob ? headers : { 'Content-Type': 'application/json', ...headers },
body: method === 'GET' ? undefined : (isBlob ? body : JSON.stringify(body)),
}).catch(() => null);
if (!res) return { ok: false }; // no network: retry later
if (res.ok) return { ok: true, body: await res.json().catch(() => null) };
if (res.status >= 500 || res.status === 408 || res.status === 429) return { ok: false };
// A permanent refusal (400, 403, 404, 409...). See "A refused write" below.
await keepForAPerson({ url, method, body, meta, status: res.status });
return { ok: true };
}
ok: false means "try again later". The outbox is first in, first out:
a patch that fails stops the patch queue there, and a blob that fails stops the
blob queue there, until the next flush. Return ok: false only for a failure
that can succeed on retry.
2. Declare a synced resource
kit.defineSyncedResource({
name: 'trip',
load: (id) => `/api/trips/${id}`, // a URL string
patch: (id) => ({ url: `/api/trips/${id}`, method: 'PATCH' }),
});
3. Read, edit, and queue files
const { data, fromCache, fetchedAt } = await kit.readSynced('trip', tripId);
readSynced(name, id) fetches when online and saves the result. When offline,
or when the fetch fails, it returns the last saved copy with fromCache: true
(and data: null if there is none). primeCache(name, id, data) saves a copy
you already have, such as rows from a list response, so a later offline read
of that record finds it.
await kit.editSynced('trip', tripId, { status: 'completed', notes });
editSynced(name, id, fields) compares fields with the cached copy and keeps
only the fields that changed (a shallow === compare). If nothing changed it
does nothing. Otherwise it writes the new values into the cache at once, so the
next read shows them, and queues one patch. Repeated edits to one record
coalesce into one queued patch, newest value per field. The patch is sent as
{ "status": "completed", "notes": "…", "_fieldTimes": { "status": 1730300000000, "notes": 1730300000000 } }
queueBlob(name, { url, method, blob, headers, idemKey, meta }) queues a file
upload (a photo, a receipt). Blobs are sent after every queued patch, each with
its idemKey, so the server can drop a duplicate if an acknowledgment was lost.
meta is passed back to request untouched; use it to tell kinds of blob
apart, since request is not told the resource name.
Edits and blobs flush on their own when the kit is online. Call
flushNow() to flush at any other time; it resolves to
{ sentPatches, sentBlobs, remaining }.
4. Wire online state
isOnline is read once, at construction. After that the kit only knows
what you tell it through setOnline(bool):
window.addEventListener('online', () => { kit.setOnline(true); kit.flushNow(); });
window.addEventListener('offline', () => kit.setOnline(false));
On React Native, feed setOnline from your connectivity listener the same way.
5. Show sync status
const unsubscribe = kit.subscribe(({ online, pendingCount, lastSyncedAt, flushing }) => {
renderSyncChip({ online, pendingCount });
});
subscribe(fn) calls fn at once and again on every change, and returns an
unsubscribe function. getStatus() returns the same object synchronously.
pendingCount counts queued patches plus queued blobs.
6. Apply field-level last-write-wins on the server
The table needs a field_ts JSONB column that maps a column name to the client
timestamp of the write that last set it, plus a tenant column and an
updated_at column:
ALTER TABLE trips ADD COLUMN IF NOT EXISTS field_ts jsonb NOT NULL DEFAULT '{}'::jsonb;
In the PATCH route, take the _fieldTimes the kit sent and hand the fields to
applyFieldLWW:
import { applyFieldLWW } from '@aventoapp/framework/server';
const EDITABLE = ['status', 'notes', 'pax_count'];
router.patch('/api/trips/:id', async (req, res) => {
const { _fieldTimes, ...body } = req.body;
// Only columns you allow. applyFieldLWW refuses a name that is not a plain
// SQL identifier, but deciding which columns a client may write is yours.
const fields = Object.fromEntries(Object.entries(body).filter(([k]) => EDITABLE.includes(k)));
if (_fieldTimes && typeof _fieldTimes === 'object') {
const result = await applyFieldLWW(client, {
table: 'trips',
id: req.params.id,
tenantId: req.tenant.id,
fields,
fieldTimes: _fieldTimes,
});
if (result.notFound) return res.status(404).end();
// result.applied: columns written; result.skipped: columns a newer write already set
} else {
// An online client sent no timestamps: your normal update path.
}
res.json(await readTrip(req.params.id));
});
applyFieldLWW(db, { table, id, tenantId, fields, fieldTimes, idCol = 'id', tenantCol = 'tenant_id', fieldTsCol = 'field_ts' }) reads the row's
field_ts, writes each field whose incoming timestamp is greater than or equal
to the stored one, stores the new timestamps, and sets updated_at = now().
A field with no timestamp counts as 0, so it loses to any timestamped write
and wins only where nothing was ever stamped. db is anything with
query(sql, params): pass the client of the transaction you are in, so the
read and the write see the same row. It resolves to
{ applied, skipped }, or { applied: [], skipped, notFound: true } when no
row matches the id and tenant.
buildFieldLWWUpdate({ fields, fieldTimes, storedFieldTs }) is the same
decision with no database, for tests or for a store that is not Postgres.
Things to get right
- A refused write must leave the queue. Returning
ok: falsefor a 403 or a 409 retries identical bytes forever and stops every patch (or blob) queued behind it. Save the payload somewhere a person can see it, then returnok: trueso the kit drops it. Write that copy first: a stuck queue can be recovered, a dropped record cannot. - Shared devices need one queue per user. IndexedDB belongs to the origin,
not to the signed-in user. On a tablet several people sign in to, wrap the
idbyou pass the kit so theoutboxandcachekeys carry the user id; otherwise the next person's flush sends the last person's edits under their own login, and sees their unsent values as if the server held them. - One tab at a time. The outbox serializes its own writes within a page, not across tabs. Two tabs open on one origin can each flush the same queue.
editSynceddiffs against the cache. Read (orprimeCache) a record before editing it offline, or every field you pass counts as changed.
Reference
| Export | Subpath |
|---|---|
createOfflineKit({ idb, clock, isOnline, request }) | /offline |
createIdb({ name, indexedDB }) | /offline |
createClock({ nowFn }) | /offline |
diffFields(prev, next) | /offline |
coalescePatch(queue, entry) | /offline |
createStore, createOutbox, createSyncer | /offline (the kit's parts, for a custom assembly) |
OFFLINE_KIT_VERSION | /offline |
applyFieldLWW(db, opts), buildFieldLWWUpdate(opts) | /server |
Kit methods: defineSyncedResource(def), readSynced(name, id),
primeCache(name, id, data), editSynced(name, id, fields),
queueBlob(name, opts), flushNow(), setOnline(bool), getStatus(),
subscribe(fn).
Releasing
Releases publish from GitHub Actions with npm Trusted Publishing
(.github/workflows/publish-framework.yml), so no token and no 2FA prompt.
Bump version in package.json (and the packages/framework entry in the
root package-lock.json), commit, then push a tag matching the version:
git tag framework-v0.2.1
git push origin master framework-v0.2.1
The workflow checks that the tag matches the version, runs the offline kit's
tests, and runs npm publish, whose prepublishOnly builds dist/ first.