跳转到内容

durable-pg

@asm/durable-pg 实现 Durable 的 Storage。应用提供 PgPoolLike 和 RedisClientLike,包本身不安装数据库驱动。

以下是宿主集成片段,pool 和 redisClient 分别是应用已创建的 PostgreSQL、Redis 适配器:

import { createSession } from "@earendil-works/pi-durable";
import {
PostgresStorage,
RedisLockManager,
withTenantContext,
} from "@asm/durable-pg";
const sessionKey = "chat:demo-user";
const context = withTenantContext({
tenantId: "demo-tenant",
scopeId: "demo-store",
sessionKey,
});
const locks = new RedisLockManager(redisClient);
await locks.withLock({ kind: "session", sessionKey }, async (lockedContext) => {
const storage = await PostgresStorage.open(
{ pool, tenantId: "demo-tenant", sessionKey },
lockedContext,
);
const session = createSession(storage);
try {
// 在此执行会话操作,并继续传递 lockedContext。
console.log(storage.tenantId, storage.scopeId, storage.sessionKey);
} finally {
await session.close(lockedContext);
}
}, context);

withTenantContext(tenant, parent?, signal?) 绑定身份,拒绝替换已绑定的租户、范围或显式会话键。入口认证由宿主完成;也可用 resolveTenantIngressMiddleware(resolver) 封装宿主自己的身份解析器。

数据库使用强制行级安全策略(FORCE RLS),按事务设置 app.tenant_id。运行角色必须不是超级用户,也不能具有 BYPASSRLS 权限;RLS 是租户边界,不会代替应用对门店和会话的授权。

存储命名空间按以下顺序确定:

options.sessionKey → Context 中的 sessionKey → scope:<scopeId>

同一租户、同一范围中的并发聊天应使用不同键,例如 chat:demo-user 与 chat:another-user。Routine 使用 routine:<runKey>。每个命名空间有独立的根会话、任务恢复范围和写入版本;只改聊天标题不会建立这种隔离。

重新打开一个可写实例会增加该命名空间的 leaseEpoch。提交必须匹配租约代次和版本,旧写入者即使还在运行也无法覆盖新写入者;不同 sessionKey 互不废止租约。

RedisClientLike 提供 set(key, token, "PX", ttlMs, "NX") 和 eval(...)。不要直接假定它与 Routine 的 sendCommand 适配器相同。

RedisLockManager 默认租约为 30 秒,命令超时为 1 秒,并周期续租。争用抛出 LockBusyError,Redis 故障抛出 RedisUnavailableError,不会自动回退到数据库锁。宿主应禁用 Redis 离线排队和自动重试,并响应 Context 的取消信号。

PostgresStorage.open() 自动调用 ensurePostgresMigrations(pool),同一个连接池复用迁移初始化结果。初始化角色需要建表权限;需要显式重跑时调用 applyPostgresMigrations(pool)。关闭 Session / Storage 会等待已接纳操作完成,但不会关闭应用共享的 pool。

需要维护范围树时,在初始化表结构后调用 migrateScopeMappings(pool, completeMappings, context),用完整列表原子替换当前租户的映射;readScopeMappings(pool, context) 读取当前映射。它不会改写历史会话所属范围。

源码依据:公共导出、存储、驱动接口、锁。