@aventoapp /框架
服务器和web模块的多租户SaaS应用程序建立在Avento。
npm i @aventoapp/framework
| 子路径 | 它包含什么 |
|---|---|
@aventoapp/framework/server | Express中间件、数据库客户端、工作流引擎、服务和服务器的一半脱机套件(applyFieldLWW) |
@aventoapp/framework/web | React web模块 |
@aventoapp/framework/shared | 由服务器和web共享的代码 |
@aventoapp/framework/offline | 离线同步套件:读缓存,写发件箱,同步引擎。不需要React,不需要DOM |
@aventoapp/framework/native | React 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/ 第一。