@aventoapp/framework

中文文档
0.2.1发布于 2026/10/31MITES Module

Avento framework - server and web modules for multi-tenant SaaS apps

原始使用文档
文档目录10 节
  1. Offline sync kit
  2. 1. Create the kit
  3. 2. Declare a synced resource
  4. 3. Read, edit, and queue files
  5. 4. Wire online state
  6. 5. Show sync status
  7. 6. Apply field-level last-write-wins on the server
  8. Things to get right
  9. Reference
  10. Releasing

@aventoapp/framework

Server and web modules for multi-tenant SaaS apps built on Avento.

npm i @aventoapp/framework
SubpathWhat it holds
@aventoapp/framework/serverExpress middleware, database clients, workflow engine, services, and the server half of the offline kit (applyFieldLWW)
@aventoapp/framework/webReact web modules
@aventoapp/framework/sharedCode shared by server and web
@aventoapp/framework/offlineThe offline sync kit: read cache, write outbox, sync engine. No React, no DOM required
@aventoapp/framework/nativeReact 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): createOfflineKit plus 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: false for 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 return ok: true so 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 idb you pass the kit so the outbox and cache keys 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.
  • editSynced diffs against the cache. Read (or primeCache) a record before editing it offline, or every field you pass counts as changed.

Reference

ExportSubpath
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.

版本与发布记录

@aventoapp/framework 共发布 3 个版本,首次发布于 2026/06/09;过去 12 个月发布了 3 个版本。

版本发布日期距今
0.2.1latest2026/10/312 天前
0.2.02026/07/024 个月前
0.1.02026/06/094 个月前

关于 @aventoapp/framework 的常见问题

@aventoapp/framework 是什么?

Avento框架——多租户SaaS应用的服务器和web模块

如何安装 @aventoapp/framework?

在项目目录运行 npm install @aventoapp/framework,或使用 pnpm add / yarn add / bun add。

@aventoapp/framework 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/aventoapp__framework 是否存在。

@aventoapp/framework 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@aventoapp/framework 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@aventoapp/framework 可以商用吗?

@aventoapp/framework 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@aventoapp/framework 还在维护吗?

最新版本 0.2.1 发布于 2026/10/31(2 天前);至今共发布 3 个版本,其中过去 12 个月发布了 3 个;首次发布于 2026/06/09。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。