@baukit/data-contracts
运行时间中性数据合同,测量辅助器,以及可执行适配器兼容性测试.
软件包定义了 JSON 密钥/值存储,ID顺序记录存储,并用有边框的密钥集 pagination,原子交易回调,以及chema元数据/迁移常规. 它刻意包含没有产品实体,也没有Expo SQLite,Dexie,或节点数据库适配器. 船上的储能适配器是 WebStorageKeyValueStore,因为Web存储不需要依赖.
使用合同
import type { StoredRecord, TransactionalStorageStore } from '@baukit/data-contracts';
interface Note extends StoredRecord {
title: string;
}
type NoteDatabase = TransactionalStorageStore<Note>;
TransactionalStorageStore 使筑巢和生命周期行为明确.
内召回,呼叫 withTransaction 交易范围上下文
加入环境交易。 调用根库是独立的,必须
由适配器序列化. 之后 close() 解决, 所有操作拒绝
与 StorageError.code === "storage_closed". 使用配额失败
storage_quota_exceeded;呼叫者不需要解析提供者错误文本.
KeyValueStore.clearPrefix(prefix) 删除开头的每个密钥 prefix,例如所有
一个账户的草稿,当密钥看起来像 draft:<account>:<document>。匹配为
uTF-16 代码单位精确且区分大小写, 无字符为通配符, 且前缀为空
清理商店. clear() 整个店都空了.
WebStorageKeyValueStore 是一个 KeyValueStore 结束 sessionStorage 或者说 localStorage它存储
每个值作为 JSON 文本在您通过的名称空间下, 所以 clear() 财务报告和审定财务报表 clearPrefix() 永不删除
来源的其他密钥。 空名空间丢出 RangeError。存储失败拒绝
以满载的、 StorageError.code === "storage_quota_exceeded"。 。 。 .
import { WebStorageKeyValueStore } from '@baukit/data-contracts';
const drafts = new WebStorageKeyValueStore(sessionStorage, 'app:drafts:v1:');
await drafts.set('note:new', { title: '' });
await drafts.clearPrefix('note:');
年长者 StorageTransaction 财务报告和审定财务报表 Transaction 接口仍然可用
对于只执行原始表面的适配器.
资源预算计量
进口生产计量 /limits 子路径。 检查返回测量并允许
值,或抛出 LimitExceededError 和同样的田地。 产品选择每个允许的值和
将错误映射到他们自己的原因代码中.
import {
checkCompactJsonUtf8Bytes,
checkTrimmedUnicodeScalars,
} from '@baukit/data-contracts/limits';
checkTrimmedUnicodeScalars(' e\u0301 ', 2);
checkCompactJsonUtf8Bytes({ value: 'é' }, 14);
文本测量修饰 White_Space 两端有相机 它没有使案文正常化.
压缩 JSON 测量接受无效的,布尔的,有限度的数,只使用scalar的字符串,密集的数组,
和纯洁的物体。 普通对象可能使用可假设的自有字符串键. 不可承担财产是:
被忽略 。 符号密钥,访问器,自定义原型,循环引用,不支持的值,
非无限数量, 和无奖代孕投掷 ResourceMeasurementError。紧凑编码器
用途 JSON.stringify 属性命令,尽管属性命令不能改变所计量的字节数.
现有的产品帮助者可以一次迁移一次呼叫. 替换 codePointLength(value.trim())
与 trimmedUnicodeScalarCount(value)替换一个 JSON.stringify 加号 TextEncoder 字节计数
与 compactJsonUtf8Bytes(value)。不同于原始 JSON.stringify,帮助者拒绝 JSON 的值
将省略或替换为 null。 。 。 .
checkRange(measured: number, minimum: number, maximum: number): LimitRange 包括检查
边框和返回 { measured, minimum, maximum }最低值或最高值
最大抛出量 LimitOutOfRangeError 和那些田园。 所有论据都必须是安全的
整数. 倒转边框 RangeError。零和等界限是有效的.
import { checkRange, enforceLimit } from '@baukit/data-contracts/limits';
enforceLimit('intervalSeconds', 'out_of_range', () => checkRange(3600, 3600, 2678400));
enforceLimit 地图 LimitOutOfRangeError 改为 LimitRangeError,一个子类 LimitError。 。 。 .
它携带 reason: 'out_of_range', (中文(简体) ), field, (中文(简体) ), measured, (中文(简体) ), minimum,以及 maximum继承
allowed 等于 maximum 对于已读取最大限制错误的呼叫者。 无效的参数
和倒置的界限 RangeError 和通过而不成为政策拒绝.
限制政策文件
parseLimitsPolicy(value, schema) 验证产品的限制政策文件,例如
limits.json 模板生成。 计划名称为支持的版本和每个版本的密钥
节。 解析器需要字符串 $comment准确的版本 准确的章节和钥匙集,
和正安全整数。 所列密钥 allowZero 作为 section.key 还接受零。 任何其他
形状抛出 LimitsPolicyError,其信息将失败的路径命名,但绝不是一个值。 通过
计划 as const 因此结果类型跟随声明的密钥。 添加范围路径到 rangeKeys
作为 section.key。这些条目完全包含 { minimum, maximum },具有非负安全性
整数界限和 minimum <= maximum。不能在 allowZero。 。 。 .
const schema = {
version: 1,
sections: { text: ['max_characters'], ranges: ['interval_seconds'] },
rangeKeys: ['ranges.interval_seconds'],
} as const;
const policy = parseLimitsPolicy({
$comment: 'Reviewed limits.',
version: 1,
text: { max_characters: 200 },
ranges: { interval_seconds: { minimum: 3600, maximum: 2678400 } },
}, schema);
// policy.ranges.interval_seconds has minimum and maximum number properties.
enforceLimit(field, reason, check) 地图 LimitExceededError 输入 LimitError,包含
reason, (中文(简体) ), field, (中文(简体) ), measured,以及 allowed。它绘制失败范围 LimitRangeError 作为
说明。 其他错误通过.
import { checkTrimmedUnicodeScalars, enforceLimit, parseLimitsPolicy } from '@baukit/data-contracts/limits';
const schema = { version: 1, sections: { text: ['max_characters'] } } as const;
const policy = parseLimitsPolicy(limitsJson, schema);
enforceLimit('title', 'text_too_long', () =>
checkTrimmedUnicodeScalars(title, policy.text.max_characters),
);
跨领域规则,例如一个限制不得超过另一个限制,在产品代码之后保留 剖析.
导入信封
这个 /import-envelope 子路径将导入安全与产品的文件格式区分开来. 产品
解码器返回安全上下文和可移动的已命名行对象. prepareImportEnvelope 检查
调用解码器前的源字节限制, 当超过行限制时停止, 拒绝
允许列表以外的字段,检查被允许的字段内的字符串,解码每行,以及
运行产品预览规划器。 计划员接收解码行,但没有交易适配器.
import {
commitImportEnvelope,
prepareImportEnvelope,
} from '@baukit/data-contracts/import-envelope';
const preview = await prepareImportEnvelope({
source,
limits: { maxSourceBytes: 2_097_152, maxRows: 5_000, maxStringBytes: 8_192 },
decodeEnvelope: decodeProductExport,
fieldAllowlist: { notes: ['id', 'title', 'body'] },
decodeRow: decodePortableRow,
plan: planImport,
});
await commitImportEnvelope({
preview,
transaction: repositories,
write: writeImportPlan,
afterCommit: () => requestSync(),
});
解码器仍然拥有格式和计划版本,需要的字段,重复的ID,墓碑
政策和实体解码。 策划者拥有冲突,出处,以及删除顺序.
commitImportEnvelope 将完整的产品写到一个后面 withTransaction 电话 它引用
afterCommit 只有在那个电话决定之后 保留 afterCommit 因为它的失败是无能的
不退缩承诺的交易.
这个API是添加剂。 现有的导入代码可以通过包装其当前解码器和预览来迁移
首先计划者,然后将写入循环移动到 commitImportEnvelope。不需要文件
格式或数据库迁移.
出口
这个 /export 子路径是导入信封的对应 . encodeCsv 将行变为 RFC
4180 CSV 字符串:每个记录以 CRLF 结尾,当其中包含逗号时引用一个单元格,双键
引文, CR, 或 LF 。 一个空单元格的一行写为 "" 所以读者仍然看到一个领域.
帮助者没有运行时间依赖性;产品将字符串写入文件并共享.
import { csvNumeric, encodeCsv } from '@baukit/data-contracts/export';
const csv = encodeCsv(
[
['note', 'amount', 'price'],
['=HYPERLINK("https://example.test")', -5, csvNumeric('1.50')],
],
{ byteOrderMark: true },
);
公式在默认情况下中和 。 文本单元格在第一个时会得到一个主原子
字符为 =, (中文(简体) ), +, (中文(简体) ), -, (中文(简体) ), @,标签,或CR,或何时 =, (中文(简体) ), +, (中文(简体) ), -,或 @ 跟踪主空格,
选项卡, CR, 或 LF。 只检查这些 ASCII 字符 。 气压会改变值 所以
文档中您自己的进口商必须读取它或者用
neutralizeFormulas: false。仅对从未打开的机器读取文件使用选择退出
电子表格.
文本看起来像负数,例如 '-5',是中和的。 传递真实数字为
有限制 number 或作为 csvNumeric(text) 格式化时,例如 '1.50'数字
单元格必须与 JSON 数字语法相匹配,并且不作修改. null 是一个空单元格.
byteOrderMark: true 以 U+FEFF 为电子表格程序启动输出,该电子表格程序需要它来检测
UTF-8 (英语).
默认 null 财务报告和审定财务报表 '' 两者都写出一个空域,所以读者不能将它们区分开来. 两个选择
为您自己的进口商读取的文件保留它们:
quoteAllCells: true引用每个文本和数字单元格。 虚无的细胞没有被引用,所以''变成""财务报告和审定财务报表null保持空闲。 一行只保留一个空格""。 。 。 .nullMarker: '\\N'写入未引用的无单元格标记并引用相同的文本单元格 内容,所以"\N"读回为文本。 标记必须非空而无双引号, 逗号、CR和LF;encodeCsv丢出一个RangeError否则.
encodeCsv([[null, '\\N', '', 'x']], { nullMarker: '\\N' }); // \N,"\N",,x
encodeCsv([[null, '', -5]], { quoteAllCells: true }); // ,"","-5"
CsvEncodeError.code 实值 invalid_numeric_cell 非无限数字或外部数字单元格
jSON语法, invalid_unicode 和未受宠的代孕者在一起 unsupported_cell
对于任何其他运行时间值。 错误带有 rowIndex 财务报告和审定财务报表 columnIndex 永远不要牢房
数值. baukit_core::export::encode_csv 在 Rust 中通过相同的向量
(单位:千美元)fixtures/export-csv/csv-encoding-v1.json) (中文(简体) ).
ShareOutcome 产品将文件交给平台后发生的事情:
shared: 共享表或Web Share API接受文件. 一些本地共享表报告 即使用户没有选择目标就关闭了它们.saved:文件被写到用户可以打开的地方,如浏览器下载.cancelled:用户解除共享或保存UI,平台也这么说,例如用一个 网络共享AbortError。 。 。 .unavailable:运行时间没有共享或保存机制,所以没有尝试.failed:尝试了一种机制,并报告了取消以外的错误.
SHARE_OUTCOMES 列出相同的数值。 软件包尚未执行 Expo 或浏览器.
已修改写入
这个 /revisioned-writes 子路径每次发送一个服务器文档的编辑, 每个选中
反对修改。 它没有反应依赖。 用钩子把它包起来 useSyncExternalStore页:1
抓取对象仅在状态变化时更改 .
import { createRevisionedWriteQueue } from '@baukit/data-contracts/revisioned-writes';
const queue = createRevisionedWriteQueue({
initial: { scope: accountId, document: noteId, acknowledgedRevision: loaded.revision },
write: async ({ scope, document, value, expectedRevision, afterUnknownOutcome, signal }) => {
const response = await api.saveNote(scope, document, value, {
ifMatch: expectedRevision,
idempotencyKey: await keyFor(document, value, expectedRevision),
signal,
});
if (response.status === 409) {
return { kind: 'conflict', currentRevision: response.revision, conflict: response.body };
}
if (!response.ok) {
return { kind: 'rejected', error: response.problem };
}
return { kind: 'accepted', revision: response.revision };
},
signal: pageLifetime.signal,
});
queue.enqueue(editedValue);
await queue.flush();
该快照将两个值分开. acknowledgedRevision 是服务器的最后一个版本
已经确认. unsent 是尚未写入的本地值。 写出它所花费的值
从 unsent 经确认的修订为 expectedRevision编辑时绘制的
飞去新鲜的 unsent 状态 dirty承认老年人
值从不标出保存的较新的值。 排队编辑与 coalesce(older, newer), (中文(简体) ),
默认保存新值。 写入补丁时传递自己的功能
页:1.
enqueue 从不发送。 在产品中跳出并呼叫 flush并行 flush 呼叫共享一个
排出,当队列被闲置或被暂停时,排出排出排出即可解决快照.
书面回调报告三种结果之一:
accepted存储新修订并发送下一个未发送的值.rejected表示服务器在接受之前拒绝写入. 值会回到unsent,与较新的编辑合并,状态是failed。 。 。 .flush或者说retry又发来了.conflict以服务器的修订和冲突有效载荷暂停队列 。 没有更多的发送 直到产品调用reset和它相信的修订。 为了保持本地价值,reset改为 当前服务器修改和enqueue又是这个值.
从回信书中扔出,或者拒绝承诺,意味着结果不明. 服务器可能
已经应用了文字并丢失了回复。 队列移动到 unknown-outcome 并保留
准确价值和预期订正 uncertain,除了较新的编辑. flush 无所事事
那个状态. retry 发送相同的值和修订版 afterUnknownOutcome: true,所以请
产品可以重用其一能键。 否则读取服务器并拨打 reset。队列永远不会
将未知结果视为在服务器接受写入之前发生的故障.
reset(next) 是账户或文档切换器的生成栅栏。 它中止当前
通过 signal 回到了召回, 放弃了不在场和不确定的状态, 并忽略
旧世代的任何后期结果. cancel() 相同,并保留队列 cancelled
直到下一个 reset终止注入 signal 选项调用 cancel。 。 。 .
状态为 cancelled, (中文(简体) ), writing, (中文(简体) ), failed, (中文(简体) ), unknown-outcome, (中文(简体) ), conflict, (中文(简体) ), dirty,或 idle输入
优先级. error 拿着 rejected 错误或抛出值.
持久草案
这个 /durable-draft 子路径在任意 KeyValueStore所以它活下来了
重新装入或崩溃 。 它没有反应依赖.
import { createDurableDraft, type DraftCodec } from '@baukit/data-contracts/durable-draft';
const noteCodec: DraftCodec<NoteDraft> = {
version: 2,
encode: (value) => ({ body: value.body, baseRevision: value.baseRevision }),
decode: (value, version) => decodeNoteDraft(value, version),
};
const draft = createDurableDraft({
store,
key: (scope: { accountId: string; noteId: string }) =>
`note-draft:${scope.accountId}:${scope.noteId}`,
codec: noteCodec,
});
await draft.open({ accountId, noteId }, { body: loaded.body, baseRevision: loaded.revision });
draft.update({ body: editedBody, baseRevision: loaded.revision });
await draft.save();
储存库 { version, value }。解码器拥有该值 : encode 返回 JSON,并 decode
接收未选中的 JSON 加上存储的版本并返回 decoded, (中文(简体) ), corrupt,或
unsupported-version中验证每个字段 decode从旧版本解码的值
算作升级,所以草稿打开了脏而下一个 save 重写. 帮助者返回
unsupported-version 不打电话 decode 当存储的版本在上方 codec.version, (中文(简体) ),
财务报告和审定财务报表 corrupt 当信封不整齐或 decode 掷出. 保留服务器基础修订版或 ETag
当产品需要后期冲突检查时,在值内.
一个公开的快照有两组状态. recovery 说什么 open 已找到 : none, (中文(简体) ), restored, (中文(简体) ),
corrupt, (中文(简体) ), unsupported-version,或 unavailable 当读取失败时. persistence 说什么
存储正在进行中 : loading, (中文(简体) ), idle, (中文(简体) ), saving, (中文(简体) ), clearing,或 failed。 。 。 . dirty 当
价值与存储的数值不同 localRevision 计数本地更改。 一个恢复
草案也可能是肮脏的.
open 从不写或删除。 虽然复苏 corrupt 或者说 unsupported-version, (中文(简体) ), save
决定 blocked 因此,无法读取的数据保持不变。 打电话 clear({ reason: 'discarded' }) 改为
拿开.
clear 取出删除的理由 :
{ reason: 'submitted', localRevision }报告服务器在本地端确认值 修订。 快照显示submission: 'confirmed'并保持该值可见。 如果用户 修订后编辑了已储存的草案clear决定newer-edits-kept。 。 。 .{ reason: 'discarded' }删除草稿, 并将值返回给open。 。 。 .
以 a 拒绝删除失败 DraftPersistenceError 并停留在快照中作为
persistence: 'failed'在呈文之后, submission 留下来 confirmed,所以产品可以分辨
"服务器有,但本地副本可能会回来",除了"没有发送". 读,写,
删除失败使用 DraftPersistenceError 与 code: 'draft_persistence_failed' 还有一个
operation。信息从不包含密钥或值.
open 一个新的瞄准镜 栅栏前一个。 尚未开始的旧范围的储存工作
跳过并解析 stale。已经开始的工作已经完成,但不会改变新的
示意图. 所有存储工作都按顺序进行,所以从A切换到B,然后回到A读取A的最后一个
写左. close() 不保存; 调用 save 打开 pagehide 或当应用程序移动到
背景情况.
move(scope) 将开放的草案交给另一个范围。 通常的情况是"新注"草稿
服务器创建刚刚成功 :
await draft.clear({ reason: 'submitted', localRevision });
await draft.move({ accountId, noteId: created.id });
当值是脏的或来自存储, move 首先在新密钥下写入, 替换
任何存储在那里。 然后删除每个案件中的旧密钥。 价值, localRevision,以及
submission 保持原样,然后保存到新键。 腐败或不支持的草案
决定 blocked。如果写入或删除失败, move 以 DraftPersistenceError, (中文(简体) ),
草案保留在旧范围,呼吁 move 再来一遍. move 不记得在哪里
草稿不见了 如果第二个编辑器仍然持有旧范围,产品会储存自己的指针
旧范围到新范围.
isScopeActive 当几个编辑可以拥有相同范围时, 例如两个标签时, 栏杆会写
与共享租赁。 帮助者在每次写入或删除前拨打 save, (中文(简体) ), clear,以及
move,对于移动的两个范围,当排队工作开始。 当它返回错误的操作
决定 stale 而存储是不被触及的。 读书不是被围住的.
const draft = createDurableDraft({
store,
key: noteDraftKey,
codec: noteCodec,
isScopeActive: (scope) => lease.holds(scope.accountId),
});
截图通过阅读 getSnapshot() 财务报告和审定财务报表 subscribe,哪个西装 useSyncExternalStore编辑
存储值本身总是同步读取,因为 KeyValueStore 是一个同步结束
有索引的DB和SQLite. 装入状态 persistence 实值 loading与
WebStorageKeyValueStore 维持一个小任务.
认证分区
deriveScopedStoreName(namespace, subject) 修剪有长度限制的条子
身份与 SHA-256 并返回不透明名称。 浏览器和节点运行时间
globalThis.crypto.subtle。反应土著必须安装符合标准
调用默认帮助器前的 Web Crypto 复方填充, 或注入一个
ScopedPersistenceDigest; 生成的世博会模板注入 expo-crypto。 。 。 .
保留 ScopedPersistenceRegistryStore 在域数据库之外(例如,
安全 Store 或 本地 Storage . resolveScopedStore 通过一个序列化访问
注册实例并验证所有版本元数据.
格式错误、 未知版本、 不一致、 或 文摘相匹配的元数据
PersistenceIdentityMismatchError 有代码
persistence_identity_mismatch 在域名商店打开之前.
商店名称可能只属于一个登记册条目,
包括跨名称空间.
ScopedPersistenceLifecycle 立刻藏起坚固的手柄,紧紧地关上
打开另一个主题, 重置产品提供的用户扫描内存,并发布
只有开口/移入/水分。 打电话 handleSessionExpired() (单位:千美元)
终端认证过期; 它关闭和块而不创建
主题切换。 为了证明理由 profile_erased电话 handleProfileErased()
带有配置的 erase 钩. 它关闭并删除最后一个选中分区,
删除其注册项, 结束时以理由被屏蔽 profile-erased编辑
cleanup 字段为 pending 当过渡被排队或运行时, complete
在消除后或当没有分区需要消除时 failed 如果关闭或关闭
擦除失败或无 erase 钩已配置 。 这个 error 是一个提示
除非清理失败, 当它持有清理错误时。 显示清理失败
仅在 cleanup === 'failed'。寿命周期保留分区
后边上下文 clear() 或者说 handleSessionExpired(),所以可以进行符号清除
在清除事件之前 清理失败保留了登记册条目。 选择
清除对象再次保留其清理结果和错误。 同时
选择不能重开已删除的配置文件; 新配置文件 ID 打开新文件
清除后分区.
清除后保留身份账户的产品必须按键分区
服务器配置文件ID,而不是身份提供者的主题.
产品可供应 resolveStore 在保存时选择自己的商店名称
共享的关闭/重置/被推出生命周期。 解析器验证了自己的
元数据与失败关闭 。 使用
recheckServerSubjectBeforeSyncAdoption 在服务器身份之前
收养或推出箱.
RecordStore.list 命令使用 JavaScript 字符串比较来升起不可移动字符串 ID。 页面大小限于 1..MAX_PAGE_SIZE,继续光标对呼叫者来说必须是不透明的。 适配器应该执行基于上一个返回的ID的密钥集光标,而不是数字偏移,所以在光标不能在后页移动之前插入.
证明适配器
维特帮手被隔离在一个只有测试的子路径中. Vitest是有意的不是同龄人依赖,因此它从未被安装在Jest或生产消费者中. 子路径没有 default 出口条件,所以杰斯特套房不能偶然解决. 在导入子路径之前, 在适配器项目的开发依赖性中安装 Vitest, 然后注册 :
import {
describeImportEnvelopeContract,
describeKeyValueContract,
describeRecordStoreContract,
describeSchemaMetadataContract,
describeScopedPersistenceContract,
describeTransactionContract,
describeTransactionalStorageContract,
} from '@baukit/data-contracts/vitest';
const makeDatabase = () => new MyAdapter();
describeKeyValueContract(() => makeDatabase().keyValues);
describeRecordStoreContract(() => makeDatabase().records);
describeTransactionContract(makeDatabase);
describeTransactionalStorageContract(makeDatabase);
describeScopedPersistenceContract(makeNamedDatabaseAdapter);
describeSchemaMetadataContract(() => makeDatabase().schemaMetadata);
describeImportEnvelopeContract(importEnvelopeOptions);
每个工厂必须归还一个新鲜而空的商店. 记录套房提供
拥有 JSON 形状 { id, label, payload } 记录。 交易执行
必须显示回调范围视图, 并显示所有回调写入
一起使用,或者如果回调/拒绝,则不使用。 更强大的复合套房
还证明了回调结果、复合物和外加箱形原子学,
重新加入、配额正常化、密切行为和同时进行
交易系列化。 测距套房证明E-F-E记录和
收发箱隔离 打开前关闭 内存重置,
腐败的注册屏蔽 服务器主体检查 终端失效 内容
适配器厂必须重开相同同名的耐用数据. 这个
包含 InMemoryStore 曝光情况 keyValues, (中文(简体) ),
records,以及 schemaMetadata 名字空间本身就在每个套房测试.
进口信封套件使用呼叫器提供的固定装置和一副新鲜的吊带. 它检查畸形
文件、无写预览、字段过滤、中途失败后回滚、光标或同步
承诺后状态变化.
持久性适配器和产品特有迁移逻辑属于产品或未来适配器包. 生产进入点没有运行时间依赖关系;只有 /vitest 子路径期望消费者的维特安装.
描述文件擦除
打电话 eraseProductProfile 仅在明确用户确认后。 编曲
eraseServerProfile 与 @baukit/api-runtime/erasure, (中文(简体) ),
eraseLocalPartition 与 ScopedPersistenceLifecycle.eraseActivePartition(), (中文(简体) ),
财务报告和审定财务报表 signOut auth 客户端本地会话的清理.
已完成或待收到确认已承诺删除服务器配置文件 .
两者都管理本地分区的去除,然后签出. 尚未收到的收据报告a
pending 因此 UI 可以解释登录账户删除仍然
结束。 当服务器运行时, 其操作 ID 可以无效 profile_erased 围栏
在不暴露行动身份的情况下确认犯罪.
一个明确的服务器故障或模糊的结果保留了本地数据和 届会。 重审一项含混不清的要求,即持续存在的一能钥匙. 服务器上钩前的故障仍然是安全的诊断警告. 局部失败 仍在尝试退出并报告两次失败;退出失败将保持不变 可见。 无论是清理失败, 都不应该触发新的服务器删除 .