Loader API
加载器发布了 Loader
服务,插件可以注入它来读取和编辑配置文件中的配置项:
import { Deferred, Effect, Layer } from "effect"
import { Graft, Plugin } from "@graftland/graft"import { Entry, Loader, ModuleResolver } from "@graftland/loader"
const admin = Plugin.make({ name: "app/admin", inject: [Loader.Loader], apply: () => Effect.gen(function* () { const loader = yield* Loader.Loader const id = yield* loader.create({ name: "./plugins/cron.mjs" }) yield* loader.update(id, { config: { every: "5 minutes" } }) yield* loader.update("api", { disabled: true }) }),})
const cron = Plugin.make({ name: "app/cron", apply: () => Effect.void })const api = Plugin.make({ name: "app/api", apply: () => Effect.void })const resolver = ModuleResolver.fromRecord({ "./plugins/cron.mjs": cron, api })
const entries = await Effect.runPromise( Effect.gen(function* () { const loaderFork = yield* Graft.plugin(Loader.plugin, [ { id: "api", name: "api" }, ]) yield* loaderFork.await const fork = yield* Graft.plugin(admin) yield* fork.await const read = yield* Deferred.make<ReadonlyArray<Entry.Entry>>() const probe = Plugin.make({ name: "app/probe", inject: [Loader.Loader], apply: () => Loader.Loader.pipe( Effect.flatMap((loader) => Deferred.into(loader.entries, read)), Effect.asVoid, ), }) yield* Graft.plugin(probe) return yield* Deferred.await(read) }).pipe( Effect.scoped, Effect.provide(Layer.provideMerge(Graft.layer, resolver)), ),)
entries[0]?.disabled // => trueentries[1]?.config // => { every: "5 minutes" }树中存在受管理 fork 时,Loader 在调用存储修改之前拒绝编辑、刷新和重试。绕过保护见执行转换。
通过代码编辑配置项
Section titled “通过代码编辑配置项”| 成员 | 作用 |
|---|---|
entries |
最新的已持久化目标配置项。 |
changes |
当前已持久化目标及后续接纳的目标,不代表运行树已协调完成。 |
create(entry, options?) |
将配置项添加到根列表末尾,或添加到 parent 分组的 position 位置,返回其 id。 |
update(id, patch) |
修改配置项的部分字段。 |
remove(id) |
移除配置项。 |
ready |
等待当前目标已协调到运行树,且树内没有 Loading fork。 |
retry |
重试所有失败项,并刷新整棵树的 !env 值。 |
编辑结果会写回文件。Graft 会保留文件中的注释、格式和 !env 标签。
编辑提供的保证
Section titled “编辑提供的保证”| 阶段 | 保证 |
|---|---|
| 已接纳 | 编辑已进入加载器的有界工作队列。在此之前取消调用者,编辑不生效。 |
| 已保存 | 文件已写入,目标已发布。编辑的效果到此完成。 |
| 已协调 | 插件已完成更新。编辑完成并不意味着这一点,需要使用 ready。 |
接纳之后,取消调用者只会停止等待,加载器 scope 中的 worker 会继续完成文件保存和目标发布。
- 调用者通过
Deferred接收此次操作的结果;即使调用者已经离开,失败仍会记录完整 cause。 - 关闭加载器会停止 worker、结清等待中的调用,并以
LoaderClosed拒绝新编辑。
独立的 scoped fiber 负责协调运行树,可以跳过中间目标版本。
| 你想要…… | 做法 |
|---|---|
| 等待当前目标及加载中的 fork | 在生命周期回调之外调用 ready。 |
| 在生命周期回调中提交编辑 | 可以提交,但不能在回调中等待 ready,否则会等待回调自身完成。 |
| 刷新当前已保存的目标 | 先 retry,再 ready。retry 在请求发布后就返回。 |
刷新整棵树的 !env |
loader.retry,或 loader.root.update(entries)(即使配置项没有变化也会刷新)。 |
| 持久化配置修改 | 使用普通编辑方法。 |
ready 会等待当前目标版本、排队中和执行中的环境刷新,以及所有后代离开
Loading。它将 Pending 和 Failed
视为已稳定;服务提供方启动后,依赖它的插件仍可能短暂处于 Pending 状态。因此
ready 不保证所有插件都已激活。核心的 fork.stable
屏障只反映当前 fork 状态,不能替代加载器的这些检查。
关于 retry:
retry重新发布相同目标时,changes也会发出一次通知。- 它会穿过生命周期、注入和分组包装层,对启动失败的实际插件 fork 调用
restart。 - 除非刷新后的值要求更新或重新加载,否则会保留健康实例及尚未完成的解析。
- 普通编辑会重试参与调和的分组中的失败项,但不会刷新未变的健康子树。
加载器为接纳的每个目标保留冻结的快照。
- API 编辑在入队前会复制数组和普通对象(包括元数据);接纳初始目标或刷新得到的目标时也会复制。
- 之后修改传入的对象,不会改变运行或持久化的目标;需要提交新编辑才能应用新值。
loader.entries和loader.changes暴露的数据是冻结的。
| 值 | 处理方式 |
|---|---|
| 数组和普通对象 | 复制。共享引用与环在快照中保留,但环仍然无法通过校验。 |
类实例(Env、Data 类、Date 等) |
保留原引用。请将它们作为不可变配置使用。 |
| 环境变量解析中共享的数组和对象 | 保持原样,不展开成树。身份缓存仅属于单次执行。 |
快照复制、环检测和配置比较也考虑共享图。比较时每对普通容器只访问一次;不透明值继续使用 Effect 的相等性,包括其自定义比较实现。含环或不透明值的配置在 ID 匹配时采用保守的回退方式,同名项较多时仍可能需要平方级比较。
文件如何写入
Section titled “文件如何写入”Graft 先写入临时文件,避免配置文件只写了一半。
- 在同目录以
0600权限独占创建临时文件。 - 保留目标文件的权限位及可获取的 uid/gid。新文件默认为
0600。 - 执行
fsync并关闭文件。 - 通过 rename 替换原文件。
| 情形 | 行为 |
|---|---|
| 写入失败或被中断 | Graft 删除临时文件。如果清理也失败,两个失败的 cause 会一并保留。 |
| 扩展 ACL 和扩展属性 | 不复制。 |
| 掉电 | 不对目录执行 fsync,因此不承诺 rename 在掉电后仍然有效。 |
| 解析或序列化失败 | 以 StoreError 保留原始 cause,文件不变。 |
写盘前,Graft 会重新解码最终文档,确认它与目标配置一致。
YAML anchor 与 alias
Section titled “YAML anchor 与 alias”| 情形 | 行为 |
|---|---|
| 编辑修改或删除 anchor | 受影响的 alias 会物化成保留原值的独立副本,无关 alias 保持不变。 |
| 通过 alias 的分组编辑其中的子项 | Graft 先创建本地副本,不修改源集合。 |
| entry/group 结构边界上的 alias | 在收集和分配 ID 前物化,因此 alias 分组中的匿名子项也会获得独立 ID。 |
| 自动分配 ID | Graft 先计算独立的逻辑目标,只物化受插入影响的 alias。普通插件配置不会混入生成的 ID。 |
Graft 会重新解析序列化后的 YAML,确认它与计划目标一致;不一致时返回
StoreError,文件保持不变。
与外部编辑的冲突
Section titled “与外部编辑的冲突”如果文件在 Graft 上次读取后被其他人修改,Graft 会先重新读取文件,再应用编辑。如果编辑涉及的字段也在文件中被修改,则以文件为准:Graft 会放弃这次编辑并记录警告。
临时文件准备完成后,Graft 会在 rename 前再次核对目标文件:
- 如果文件已变化,Graft 会删除临时文件、读取新版本并重新应用编辑。
- 同字段冲突仍以最初的快照为基准,因此准备期间的修改也会被检测到。
- 最多尝试三次;文件持续变化时返回
StoreError,不提交这次编辑。 - 清理失败会停止重试并保留完整 cause。
初始文件创建和自动分配 ID 后的写回也使用同样的检查。
定位托管条目
Section titled “定位托管条目”将条目 id 作为 Graft.transition 的 target;也支持数字 fork
id。Loader 在条目注册驱动前绑定名称。嵌入式注册必须显式提供
instance;名称冲突或重复注册会在启动执行单元前失败。
update、remove、create、刷新和重试仍属于绕过协调器的操作:只要存在托管后代,就会在调用 store 前拒绝。文件边界见托管配置。已接受的 transition 不改变
loader.entries;它表示持久化配置,而非当前运行的代。