@aventoapp/framework

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

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

中文使用文档
文档目录10 节
  1. 离线同步套件
  2. 1. 创建工具包
  3. 2. 声明一个同步的资源
  4. 3. 读取、编辑和排队文件
  5. 4. 线路在线状态
  6. 5. 显示同步状态
  7. 6. 在服务器上应用字段级别的last-write-wins
  8. 事情要做对
  9. 参考
  10. 释放

@aventoapp /框架

服务器和web模块的多租户SaaS应用程序建立在Avento。

npm i @aventoapp/framework
子路径它包含什么
@aventoapp/framework/serverExpress中间件、数据库客户端、工作流引擎、服务和服务器的一半脱机套件(applyFieldLWW)
@aventoapp/framework/webReact web模块
@aventoapp/framework/shared由服务器和web共享的代码
@aventoapp/framework/offline离线同步套件:读缓存,写发件箱,同步引擎。不需要React,不需要DOM
@aventoapp/framework/nativeReact Native模块

离线同步套件

该套件可以让网络或移动客户端在没有信号的情况下继续工作。读来 联机时从网络中,不联机时从上次保存的副本中。编辑是 立即应用于缓存副本,排队,并在设备返回时发送 网上。服务器解决每个字段的冲突:每个字段的最新编辑 获胜,通过客户端时间戳,所以两个人编辑一个记录的不同字段 永远不要覆盖对方。

有两部分:

  • 客户端 (/offline): createOfflineKit 加上各部分(createIdb, createClock)。
  • 服务器 (/server): applyFieldLWW,它只写入字段 传入的时间戳至少与存储的时间戳一样新。

1. 创建工具包

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 }) 用三个打开一个IndexedDB数据库 对象存储, cache, outbox 和 meta. indexedDB 是注射的,所以测试 通过 fake-indexeddb 浏览器通过了 window.indexedDB. 没有。 Kit自己读取浏览器全局变量。

createClock({ nowFn }) 时间戳每次编辑。如果你能读懂服务器的话 时间( Date 响应头,比如),调用 clock.setOffsetFromServer(serverEpochMs) 时钟错误的设备就会这样 不是每场冲突都输赢。 clock.getOffsetMs() 返回当前 偏移量。

这个 request 运输

工具箱从不呼叫 fetch 本身。每个网络呼叫都要经过 request 传递的函数,它必须解析(绝不抛出)到 { 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 意思是“稍后再试”。 发件箱是先进先出的: 失败的补丁会停止补丁队列,失败的blob会停止 Blob队列在那里,直到下一次刷新。返回 ok: false 只为一次失败 这可以在重试时成功。

2. 声明一个同步的资源

kit.defineSyncedResource({
  name: 'trip',
  load: (id) => `/api/trips/${id}`,                          // a URL string
  patch: (id) => ({ url: `/api/trips/${id}`, method: 'PATCH' }),
});

3. 读取、编辑和排队文件

const { data, fromCache, fetchedAt } = await kit.readSynced('trip', tripId);

readSynced(name, id) 在线时获取并保存结果。当离线, 或者当获取失败时,它返回最后保存的副本 fromCache: true (和 data: null 如果没有的话)。 primeCache(name, id, data) 保存副本 您已经有了,例如来自列表响应的行,因此稍后脱机读取 在那个记录中找到它。

await kit.editSynced('trip', tripId, { status: 'completed', notes });

editSynced(name, id, fields) 比较 fields 与缓存的副本和保存 只有田野的变化(浅) === 比较)。如果没有什么改变 什么也不做。否则,它会立即将新值写入缓存中,因此 下一步读取显示它们,并排队一个补丁。重复编辑一个记录 合并成一个队列补丁,每个字段的最新值。该补丁以

{ "status": "completed", "notes": "…", "_fieldTimes": { "status": 1730300000000, "notes": 1730300000000 } }

queueBlob(name, { url, method, blob, headers, idemKey, meta }) 文件排队 上传(照片,收据)。blob在每个队列补丁之后发送,每个带有 它的 idemKey,因此,如果确认丢失,服务器可以删除副本。 meta 被传递回 request 没有;用它来分辨各种斑点 分开,因为 request 未被告知资源名称。

当工具包在线时,编辑和斑点会自行冲洗。呼叫 flushNow() 在任何其他时间冲水;它决定为 { sentPatches, sentBlobs, remaining }.

4. 线路在线状态

isOnline 读取 一次在建筑方面。在那之后,套件只知道 你怎么说的 setOnline(bool):

window.addEventListener('online', () => { kit.setOnline(true); kit.flushNow(); });
window.addEventListener('offline', () => kit.setOnline(false));

在React Native上,feed setOnline 以同样的方式从连接侦听器中获取。

5. 显示同步状态

const unsubscribe = kit.subscribe(({ online, pendingCount, lastSyncedAt, flushing }) => {
  renderSyncChip({ online, pendingCount });
});

subscribe(fn) 调用 fn 在每次更改时重复一次,并返回 退订功能。 getStatus() 同步返回同一个对象。 pendingCount 对排队补丁和排队blob进行计数。

6. 在服务器上应用字段级别的last-write-wins

这张桌子需要清理一下。 field_ts 将列名映射到客户机的JSONB列 上次设置它的写操作的时间戳,加上一个租户列和一个 updated_at 专栏:

ALTER TABLE trips ADD COLUMN IF NOT EXISTS field_ts jsonb NOT NULL DEFAULT '{}'::jsonb;

在PATCH路由中,使用 _fieldTimes 工具包送去,把田交给 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' }) 读取行。 field_ts,写入输入时间戳大于或等于的每个字段 ,存储新的时间戳,并设置 updated_at = now(). 没有时间戳的字段计数为 0,因此它输给了任何带有时间戳的写入 只有在没有盖章的地方才能获胜。 db 有什么事吗? query(sql, params):传递您所在的事务的客户端,因此 读和写在同一行。它决定为 { applied, skipped },或者 { applied: [], skipped, notFound: true } 当没有 行与id和租户匹配。

buildFieldLWWUpdate({ fields, fieldTimes, storedFieldTs }) 是一样的 决定没有数据库,测试或存储不是Postgres。

事情要做对

  • 被拒绝的写必须离开队列。 返回 ok: false 如果是403或 409永远重试相同的字节,并停止排队的每个补丁(或blob) 在它后面。将有效载荷保存在一个人可以看到的地方,然后返回 ok: true 所以工具箱掉了下来。先写那个副本:卡住的队列可能是 丢失的记录不能恢复。
  • 共享设备每个用户需要一个队列。 IndexedDB属于原点, 对已登录的用户无效。在几个人登录的平板电脑上,包装 idb 你把工具箱递给我 outbox 和 cache 键携带用户id; 否则下一个人的同花顺将上一个人的编辑发送到他们的 自己登录,并看到它们未发送的值,就好像服务器持有它们一样。
  • 一次一个标签。 发件箱在页面内序列化它自己的写入, 而不是跨选项卡。在一个原点上打开的两个选项卡可以刷新同一个队列。
  • editSynced 不同于缓存。 读(或 primeCache)记录 在离线编辑之前,或者您传递的每个字段都算作已更改。

参考

出口子路径
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 (该套件的部件,用于定制组装)
OFFLINE_KIT_VERSION/offline
applyFieldLWW(db, opts), buildFieldLWWUpdate(opts)/server

组件的方法: defineSyncedResource(def), readSynced(name, id), primeCache(name, id, data), editSynced(name, id, fields), queueBlob(name, opts), flushNow(), setOnline(bool), getStatus(), subscribe(fn).

释放

使用npm Trusted Publishing从GitHub Actions发布版本 (.github/workflows/publish-framework.yml),因此没有令牌和2FA提示。 撞 version 在 package.json (和 packages/framework 进入 根 package-lock.json),提交,然后推送一个与版本匹配的标签:

git tag framework-v0.2.1
git push origin master framework-v0.2.1

工作流检查标签是否与版本匹配,运行离线工具包 测试和运行 npm publish,他的 prepublishOnly 构建 dist/ 第一。

版本与发布记录

@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 和提交记录判断。