feat(servers): add node creation and modification timestamps

CatalogEntry gains createdAt/updatedAt and the catalog document moves to
v5 (reads v1-v5, writes v5). Modification time is structure-sensitive:
editing a node's own fields bumps the node, a group's direct child set
or order changes bump the group, and a moved node keeps its timestamps.
No-op writes and unchanged subscription refreshes bump nothing; the
refresh rebuild compares per node, so surviving leaves keep theirs. All
times are passed in by callers as now: Date - one moment per logical
commit, the domain layer stays clock-free. Legacy documents load with
nil (unknown, never backfilled); duplicates and imports stamp fresh
times at the moment of the operation.

Semantics are recorded as glossary term 节点时间戳 and ADR 0031. No UI
in this change.
This commit is contained in:
Qiu Yuzhou
2026-10-09 18:04:48 +08:00
parent 1a6af3d467
commit f23a24f203
13 changed files with 456 additions and 81 deletions
+3
View File
@@ -15,6 +15,9 @@
**配置组(Configuration group)**:
具有名称和子节点顺序的容器,可包含服务器配置和嵌套配置组;每个节点最多属于一个父节点,组之间不形成环。
**节点时间戳(Node timestamps)**:
配置目录中每个节点(服务器配置或配置组)的创建时间与修改时间事实。创建时间是节点进入目录的时刻;修改时间是节点自身资料、或分组的直接子节点集合与顺序最后一次变化的时刻,移动节点本身不算修改。历史文档未记录的时间是未知事实,不做回填。
**配置组导出(Configuration group export)**:
用户请求生成的可分享文件快照,包含一个配置组及其全部后代的连接资料和所需凭据。它可以包含不具备激活条件的服务器配置。
@@ -38,17 +38,19 @@ extension CatalogWorkflow {
return try commit { catalog in
try copyEntry(
entry, from: source, into: &catalog, parent: parent,
index: position + 1, name: name, journal: &journal)
index: position + 1, name: name, journal: &journal, now: Date())
}
} catch {
throw CommitError(underlying: error, credentialRollback: journal.rollback())
}
}
/// 副本节点获得全新身份与全新时间戳(创建时间 = 复制时刻,ADR-0031);
/// 每个新建节点的落点分组按结构敏感语义更新修改时间。
private func copyEntry(
_ entry: CatalogEntry, from source: ConfigurationCatalog,
into catalog: inout ConfigurationCatalog, parent: NodeID?, index: Int? = nil,
name: String, journal: inout CredentialWriteJournal
name: String, journal: inout CredentialWriteJournal, now: Date
) throws -> NodeID {
switch entry.kind {
case .server(var fields):
@@ -57,16 +59,16 @@ extension CatalogWorkflow {
if let reference = fields.pluginOptionsRef {
fields.pluginOptionsRef = try copyCredential(reference, journal: &journal)
}
return try catalog.addServer(fields, to: parent, index: index)
return try catalog.addServer(fields, to: parent, index: index, now: now)
case .group(let fields):
let copy = try catalog.addGroup(name, to: parent, index: index)
let copy = try catalog.addGroup(name, to: parent, index: index, now: now)
for child in fields.children {
guard let childEntry = source.entry(for: child) else {
throw CatalogError.nodeNotFound(child)
}
_ = try copyEntry(
childEntry, from: source, into: &catalog, parent: copy,
name: childEntry.displayName, journal: &journal)
name: childEntry.displayName, journal: &journal, now: now)
}
return copy
}
@@ -83,8 +83,9 @@ extension CatalogWorkflow {
}
do {
try commit { catalog in
let now = Date()
for fields in prepared {
try catalog.addServer(fields, to: parent)
try catalog.addServer(fields, to: parent, now: now)
}
}
} catch {
@@ -137,9 +138,14 @@ extension CatalogWorkflow {
var journal = CredentialWriteJournal(credentials: credentials)
do {
let rootID = try commit { catalog in
try Self.mountImportSnapshot(
snapshot, fileNameFallback: Self.fileNameBase(fileName),
into: &catalog, parent: parent, journal: &journal)
// 一次导入一个写入时刻:根分组与全部后代节点共用(ADR-0031)。
let now = Date()
let base = snapshot.root.name.isEmpty ? Self.fileNameBase(fileName) : snapshot.root.name
let rootID = try catalog.addGroup(
Self.uniqueGroupName(base, in: catalog), to: parent, now: now)
try Self.mountSnapshotChildren(
of: snapshot.root, into: rootID, catalog: &catalog, journal: &journal, now: now)
return rootID
}
return .imported(count: Self.snapshotServerCount(snapshot.root), groupID: rootID)
} catch {
@@ -148,38 +154,25 @@ extension CatalogWorkflow {
}
}
/// 快照 → 手动子树:根分组整棵挂入选中落点,嵌套分组与服务器按快照顺序
/// 重建。根组名取扩展根组名,缺失(扁平回退)时以文件名兜底,重名加序号。
private static func mountImportSnapshot(
_ snapshot: SubscriptionSnapshot,
fileNameFallback: String,
into catalog: inout ConfigurationCatalog,
parent: NodeID?,
journal: inout CredentialWriteJournal
) throws -> NodeID {
let base = snapshot.root.name.isEmpty ? fileNameFallback : snapshot.root.name
let rootID = try catalog.addGroup(uniqueGroupName(base, in: catalog), to: parent)
try mountSnapshotChildren(
of: snapshot.root, into: rootID, catalog: &catalog, journal: &journal)
return rootID
}
/// 快照后代 → 手动子树:嵌套分组与服务器按快照顺序重建(根分组由调用方
/// 挂载)。嵌套组缺名以占位名兜底。
private static func mountSnapshotChildren(
of group: SubscriptionSnapshot.Group,
into groupID: NodeID,
catalog: inout ConfigurationCatalog,
journal: inout CredentialWriteJournal
journal: inout CredentialWriteJournal,
now: Date
) throws {
for child in group.children {
switch child {
case .group(let nested):
let nestedID = try catalog.addGroup(
nested.name.isEmpty ? "未命名分组" : nested.name, to: groupID)
nested.name.isEmpty ? "未命名分组" : nested.name, to: groupID, now: now)
try mountSnapshotChildren(
of: nested, into: nestedID, catalog: &catalog, journal: &journal)
of: nested, into: nestedID, catalog: &catalog, journal: &journal, now: now)
case .server(let leaf):
try catalog.addServer(
importServerFields(from: leaf.record, journal: &journal), to: groupID)
importServerFields(from: leaf.record, journal: &journal), to: groupID, now: now)
}
}
}
@@ -27,7 +27,7 @@ extension CatalogWorkflow {
try commitSubscriptionDocument { catalog, subscriptions in
// 固定分组挂在目录根(GLOSSARY.md「Subscription group」);名称以 host
// 兜底,首次成功刷新后跟随远端。
try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID)
try catalog.addGroup(url.host ?? "", source: .subscription, id: record.groupID, now: Date())
subscriptions.append(record)
}
} catch {
@@ -139,6 +139,7 @@ extension CatalogWorkflow {
var credentialJournal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
try commitSubscriptionDocument { catalog, subscriptions in
let now = Date()
// 凭据引用按节点身份复用:延续节点覆盖写秘密,不新增孤儿引用;
// journal 保证快照提交失败时恢复旧秘密(story 29)。
var reusedRefs: [NodeID: ServerCredentialRefs] = [:]
@@ -154,17 +155,17 @@ extension CatalogWorkflow {
try Task.checkCancellation()
let name = snapshot.root.name.isEmpty ? fallbackName : snapshot.root.name
let document = CatalogSubscriptionSnapshot(name: name, root: resolved)
let replacedServers = try catalog.applySubscriptionSnapshot(document, into: summary.groupID)
let replacedServers = try catalog.applySubscriptionSnapshot(
document, into: summary.groupID, now: now)
// 快照应用返回整个旧树;延续节点仍复用引用,不能按旧树直接删除秘密。
let retainedRefs = Set(Self.credentialRefs(of: Array(catalog.entries.values)))
obsoleteCredentialRefs = Set(Self.credentialRefs(of: replacedServers))
.subtracting(retainedRefs)
if let index = subscriptions.firstIndex(where: { $0.id == summary.id }) {
let succeededAt = Date()
subscriptions[index].status = .succeeded(at: succeededAt)
subscriptions[index].status = .succeeded(at: now)
subscriptions[index].information =
snapshot.information.isEmpty ? nil : snapshot.information
subscriptions[index].lastSucceededAt = succeededAt
subscriptions[index].lastSucceededAt = now
}
}
} catch {
@@ -222,6 +222,7 @@ final class CatalogWorkflow: ObservableObject {
var journal = CredentialWriteJournal(credentials: dependencies.credentials)
do {
return try commit { [self] catalog in
let now = Date()
let passwordRef = CredentialReference.fresh()
try journal.save(draft.password, for: passwordRef)
var fields = ServerFields(
@@ -231,7 +232,7 @@ final class CatalogWorkflow: ObservableObject {
try Self.applyPluginSelection(
draft.plugin, options: draft.pluginOptions, to: &fields,
credentials: dependencies.credentials, journal: &journal)
return try catalog.addServer(fields, to: parent)
return try catalog.addServer(fields, to: parent, now: now)
}
} catch {
throw CommitError(underlying: error, credentialRollback: journal.rollback())
@@ -244,7 +245,7 @@ final class CatalogWorkflow: ObservableObject {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
return try commit { catalog in
try catalog.addGroup(trimmed, to: parent)
try catalog.addGroup(trimmed, to: parent, now: Date())
}
}
@@ -253,7 +254,7 @@ final class CatalogWorkflow: ObservableObject {
let trimmed = name.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { throw ServerFormError.emptyName }
try commit { catalog in
try catalog.renameGroup(id, to: trimmed)
try catalog.renameGroup(id, to: trimmed, now: Date())
}
}
@@ -261,7 +262,7 @@ final class CatalogWorkflow: ObservableObject {
/// typed error 原样上抛。
func move(_ id: NodeID, to parent: NodeID?) async throws {
try commit { catalog in
try catalog.move(id, to: parent)
try catalog.move(id, to: parent, now: Date())
}
}
@@ -269,7 +270,7 @@ final class CatalogWorkflow: ObservableObject {
/// Keychain 秘密;返回被删身份集合供 UI 清除失效选择(story 18)。
func remove(_ id: NodeID) async throws -> RemovalOutcome {
let removed: [CatalogEntry] = try commit { catalog in
try catalog.remove(id)
try catalog.remove(id, now: Date())
}
for ref in Self.credentialRefs(of: removed) {
try? dependencies.credentials.delete(ref)
@@ -297,7 +298,7 @@ final class CatalogWorkflow: ObservableObject {
try Self.applyPluginSelection(
draft.plugin, options: draft.pluginOptions, to: &fields,
credentials: dependencies.credentials, journal: &journal)
try catalog.updateServer(id, with: fields)
try catalog.updateServer(id, with: fields, now: Date())
}
serverUpdateSubject.send([id])
} catch {
@@ -13,10 +13,10 @@ struct CatalogFileStore {
}
/// v2 起携带订阅记录;v1(无订阅字段)仍可读取。v3 移除节点级 enabled
/// 语义;v4 把订阅失败字符串替换为 typed facts。读取兼容 v1–v4,写入
/// 始终使用 v4。
private static let supportedVersions: Set<Int> = [1, 2, 3, 4]
private static let currentVersion = 4
/// 语义;v4 把订阅失败字符串替换为 typed facts;v5 增加节点创建/修改时间
/// (v1–v4 读取为 nil 未知,不回填)。读取兼容 v1–v5,写入始终使用 v5。
private static let supportedVersions: Set<Int> = [1, 2, 3, 4, 5]
private static let currentVersion = 5
private static let jsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
@@ -111,7 +111,8 @@ struct CatalogFileStore {
/// 落盘文档形态:版本号 + 根子序 + 全量节点表(顺序语义在根序与分组显子序中)
/// + 订阅记录(v2 起携带;v1 缺省为空)。v3 节点条目不再写出 `enabled`;
/// v4 的失败状态只写 typed facts,不再写旧 `reason` 字段。
/// v4 的失败状态只写 typed facts,不再写旧 `reason` 字段;v5 节点条目携带
/// 创建/修改时间,未知(历史文档迁移节点)省略键。
private struct CatalogFilePayload: Codable {
var version: Int
var rootChildren: [NodeID]
@@ -53,7 +53,7 @@ struct GroupFields: Codable, Equatable, Sendable {
}
}
/// 目录树节点:公共字段 + 服务器叶子/分组形态。
/// 目录树节点:公共字段(身份、来源、节点时间戳)+ 服务器叶子/分组形态。
struct CatalogEntry: Codable, Equatable, Sendable {
enum Kind: Codable, Equatable, Sendable {
case server(ServerFields)
@@ -63,25 +63,34 @@ struct CatalogEntry: Codable, Equatable, Sendable {
let id: NodeID
var source: NodeSource
var kind: Kind
/// 节点进入目录的时刻;历史文档未记录时为 nil(未知,不回填,ADR-0031)。
let createdAt: Date?
/// 节点自身资料、或分组的直接子节点集合与顺序最后一次变化的时刻;未知为 nil。
var updatedAt: Date?
private enum CodingKeys: String, CodingKey {
case id, source, enabled, kind
case id, source, enabled, kind, createdAt, updatedAt
}
init(id: NodeID, source: NodeSource, kind: Kind) {
init(id: NodeID, source: NodeSource, kind: Kind, createdAt: Date?, updatedAt: Date?) {
self.id = id
self.source = source
self.kind = kind
self.createdAt = createdAt
self.updatedAt = updatedAt
}
/// Reads the removed `enabled` field from v1/v2 documents only for migration;
/// its value has no meaning in the current domain model.
/// its value has no meaning in the current domain model. Node timestamps are
/// absent in v1–v4 documents and decode as nil (unknown, never backfilled).
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(NodeID.self, forKey: .id)
source = try container.decode(NodeSource.self, forKey: .source)
_ = try container.decodeIfPresent(Bool.self, forKey: .enabled)
kind = try container.decode(Kind.self, forKey: .kind)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
}
func encode(to encoder: Encoder) throws {
@@ -89,6 +98,8 @@ struct CatalogEntry: Codable, Equatable, Sendable {
try container.encode(id, forKey: .id)
try container.encode(source, forKey: .source)
try container.encode(kind, forKey: .kind)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
}
@@ -1,3 +1,5 @@
import Foundation
/// 配置目录:不可见的配置树根(GLOSSARY.md「Configuration catalog」)。有序顶层
/// 子节点为服务器配置或配置组。存储形态:全量节点表 + 根序 + 各分组显子序;
/// 单父由「一个身份只出现在一条子序里」构造保证,无环与来源分离由操作校验。
@@ -92,13 +94,16 @@ extension ConfigurationCatalog {
// MARK: - 变更
extension ConfigurationCatalog {
/// 新建节点:创建时间与修改时间都是写入时刻(ADR-0031);落点分组的
/// 直接子节点集合变化,按结构敏感语义更新该分组的修改时间。
@discardableResult
mutating func addServer(
_ fields: ServerFields,
source: NodeSource = .manual,
id proposedID: NodeID? = nil,
to parent: NodeID? = nil,
index: Int? = nil
index: Int? = nil,
now: Date = Date()
) throws -> NodeID {
let id = proposedID ?? .fresh()
guard entries[id] == nil else { throw CatalogError.duplicateID(id) }
@@ -106,7 +111,9 @@ extension ConfigurationCatalog {
throw CatalogError.subscriptionServerAtRoot(id)
}
try place(id, source: source, parent: parent, index: index, excluding: nil)
entries[id] = CatalogEntry(id: id, source: source, kind: .server(fields))
entries[id] = CatalogEntry(
id: id, source: source, kind: .server(fields), createdAt: now, updatedAt: now)
touchParent(of: id, at: now)
return id
}
@@ -116,47 +123,66 @@ extension ConfigurationCatalog {
source: NodeSource = .manual,
id proposedID: NodeID? = nil,
to parent: NodeID? = nil,
index: Int? = nil
index: Int? = nil,
now: Date = Date()
) throws -> NodeID {
let id = proposedID ?? .fresh()
guard entries[id] == nil else { throw CatalogError.duplicateID(id) }
try place(id, source: source, parent: parent, index: index, excluding: nil)
entries[id] = CatalogEntry(id: id, source: source, kind: .group(GroupFields(name: name)))
entries[id] = CatalogEntry(
id: id, source: source, kind: .group(GroupFields(name: name)),
createdAt: now, updatedAt: now)
touchParent(of: id, at: now)
return id
}
mutating func renameGroup(_ id: NodeID, to name: String) throws {
/// 重命名分组:内容未变(同名)不更新修改时间。
mutating func renameGroup(_ id: NodeID, to name: String, now: Date = Date()) throws {
guard var entry = entries[id] else { throw CatalogError.nodeNotFound(id) }
guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) }
guard case .group(var fields) = entry.kind else { throw CatalogError.notAGroup(id) }
guard fields.name != name else { return }
fields.name = name
entry.kind = .group(fields)
entry.updatedAt = now
entries[id] = entry
}
mutating func updateServer(_ id: NodeID, with fields: ServerFields) throws {
/// 更新服务器连接字段:内容未变不更新修改时间。
mutating func updateServer(_ id: NodeID, with fields: ServerFields, now: Date = Date()) throws {
guard var entry = entries[id] else { throw CatalogError.nodeNotFound(id) }
guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) }
guard case .server = entry.kind else { throw CatalogError.notAServer(id) }
entry.kind = .server(fields)
let kind = CatalogEntry.Kind.server(fields)
guard entry.kind != kind else { return }
entry.kind = kind
entry.updatedAt = now
entries[id] = entry
}
/// 移动节点(携带整棵子树):目录根与手动组之间、手动组相互之间。
/// 同父移动即重排。跨来源、共享父、成环一律拒绝。
mutating func move(_ id: NodeID, to parent: NodeID?, index: Int? = nil) throws {
/// 同父移动即重排。跨来源、共享父、成环一律拒绝。被移动节点自身内容不变、
/// 修改时间不动;新旧落点分组的子节点集合/顺序变化,按结构敏感语义更新
/// (根层无分组,静默)。
mutating func move(_ id: NodeID, to parent: NodeID?, index: Int? = nil, now: Date = Date())
throws
{
guard let entry = entries[id] else { throw CatalogError.nodeNotFound(id) }
guard entry.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) }
if let parent, parent == id || ancestors(of: parent).contains(id) {
throw CatalogError.cycleDetected(id)
}
let oldParent = parentIndex()[id]
try place(id, source: entry.source, parent: parent, index: index, excluding: id)
if oldParent != parent { touch(oldParent, at: now) }
touch(parent, at: now)
}
/// 删除节点;手动分组递归删除整棵子树。返回被删除的节点(含其凭据引用,
/// 供调用方清理 Keychain 秘密)。空组允许显式删除;空组本身持久保留。
/// 原落点分组失去子节点,按结构敏感语义更新其修改时间。
@discardableResult
mutating func remove(_ id: NodeID) throws -> [CatalogEntry] {
mutating func remove(_ id: NodeID, now: Date = Date()) throws -> [CatalogEntry] {
guard let root = entries[id] else { throw CatalogError.nodeNotFound(id) }
guard root.source == .manual else { throw CatalogError.subscriptionNodeImmutable(id) }
@@ -168,7 +194,9 @@ extension ConfigurationCatalog {
removed.append(entry)
entries[current] = nil
}
let oldParent = parentIndex()[id]
removeFromSiblings(id)
touch(oldParent, at: now)
return removed
}
@@ -254,10 +282,12 @@ extension ConfigurationCatalog {
/// 订阅快照原子应用(GLOSSARY.md 刷新契约):以快照整体重建固定分组子树;
/// 名称、结构、顺序和连接字段全部跟随远端(扩展缺失时由调用方给 URL host
/// 兜底)。返回被移除的旧服务器叶子(供调用方清理凭据)。
/// 兜底)。返回被移除的旧服务器叶子(供调用方清理凭据)。延续节点按内容
/// 比较决定时间戳:内容未变保留双时间戳,变化保留创建时间、更新修改时间;
/// 新节点两个时间戳都是写入时刻(ADR-0031)。
@discardableResult
mutating func applySubscriptionSnapshot(
_ snapshot: CatalogSubscriptionSnapshot, into groupID: NodeID
_ snapshot: CatalogSubscriptionSnapshot, into groupID: NodeID, now: Date = Date()
) throws -> [CatalogEntry] {
guard let fixed = entries[groupID], case .group = fixed.kind else {
throw CatalogError.notAGroup(groupID)
@@ -266,9 +296,11 @@ extension ConfigurationCatalog {
throw CatalogError.crossSourcePlacement(node: .subscription, container: fixed.source)
}
// 先整树摘除旧成员(固定分组本身保留),收集被移除的服务器。
// 先整树摘除旧成员(固定分组本身保留),收集被移除的服务器;旧时间戳
// 以条目载荷整体留底,重建时逐节点比较。
var removedServers: [CatalogEntry] = []
let oldSubtree = try subscriptionSubtree(of: groupID)
let previous = Dictionary(uniqueKeysWithValues: oldSubtree.map { ($0.id, $0) })
for entry in oldSubtree {
if case .server = entry.kind { removedServers.append(entry) }
}
@@ -279,8 +311,10 @@ extension ConfigurationCatalog {
// 远端权威重建:名称、结构、顺序、字段全按快照。
var fixedFields = GroupFields(name: snapshot.name, children: [])
try insertSnapshotChildren(of: snapshot.root, into: &fixedFields)
entries[groupID]?.kind = .group(fixedFields)
try insertSnapshotChildren(of: snapshot.root, into: &fixedFields, previous: previous, now: now)
entries[groupID] = Self.stampedEntry(
id: groupID, source: .subscription, kind: .group(fixedFields),
previous: previous, now: now)
return removedServers
}
@@ -288,22 +322,27 @@ extension ConfigurationCatalog {
/// 分组的显子序。身份已由解析器按订阅作用域限定,与既有节点冲突即程序错误。
private mutating func insertSnapshotChildren(
of group: CatalogSubscriptionSnapshot.Group,
into fields: inout GroupFields
into fields: inout GroupFields,
previous: [NodeID: CatalogEntry],
now: Date
) throws {
var childIDs: [NodeID] = []
for child in group.children {
switch child {
case .server(let leaf):
guard entries[leaf.id] == nil else { throw CatalogError.duplicateID(leaf.id) }
entries[leaf.id] = CatalogEntry(
id: leaf.id, source: .subscription, kind: .server(leaf.fields))
entries[leaf.id] = Self.stampedEntry(
id: leaf.id, source: .subscription, kind: .server(leaf.fields),
previous: previous, now: now)
childIDs.append(leaf.id)
case .group(let nested):
guard entries[nested.id] == nil else { throw CatalogError.duplicateID(nested.id) }
var nestedFields = GroupFields(name: nested.name, children: [])
try insertSnapshotChildren(of: nested, into: &nestedFields)
entries[nested.id] = CatalogEntry(
id: nested.id, source: .subscription, kind: .group(nestedFields))
try insertSnapshotChildren(
of: nested, into: &nestedFields, previous: previous, now: now)
entries[nested.id] = Self.stampedEntry(
id: nested.id, source: .subscription, kind: .group(nestedFields),
previous: previous, now: now)
childIDs.append(nested.id)
}
}
@@ -312,6 +351,7 @@ extension ConfigurationCatalog {
/// 删除订阅:固定分组整棵子树连同固定分组本身一并移除(递归清除,不留
/// 墓碑)。返回被删条目(含凭据引用,供调用方清理 Keychain 秘密)。
/// 固定分组的原落点(目录根)无分组形态,无修改时间可更新。
@discardableResult
mutating func removeSubscriptionSubtree(of groupID: NodeID) throws -> [CatalogEntry] {
let subtree = try subscriptionSubtree(of: groupID)
@@ -321,4 +361,36 @@ extension ConfigurationCatalog {
removeFromSiblings(groupID)
return subtree
}
/// 按内容比较决定时间戳的条目构造(ADR-0031):延续节点内容未变保留双
/// 时间戳,变化保留创建时间、更新修改时间;新节点双时间戳为写入时刻。
private static func stampedEntry(
id: NodeID,
source: NodeSource,
kind: CatalogEntry.Kind,
previous: [NodeID: CatalogEntry],
now: Date
) -> CatalogEntry {
guard let old = previous[id] else {
return CatalogEntry(id: id, source: source, kind: kind, createdAt: now, updatedAt: now)
}
let updatedAt = old.kind == kind ? old.updatedAt : now
return CatalogEntry(
id: id, source: source, kind: kind, createdAt: old.createdAt, updatedAt: updatedAt)
}
}
// MARK: - 节点时间戳(ADR-0031)
extension ConfigurationCatalog {
/// 更新节点修改时间;节点不存在或根层(nil)静默无操作。
private mutating func touch(_ id: NodeID?, at now: Date) {
guard let id, entries[id] != nil else { return }
entries[id]?.updatedAt = now
}
/// 子节点集合或顺序变化后更新其所在分组(类目录修改时间语义);根层无分组。
private mutating func touchParent(of child: NodeID, at now: Date) {
touch(parentIndex()[child], at: now)
}
}
@@ -186,9 +186,11 @@ struct LegacyImportPlanner {
existingDocument: CatalogDocument
) throws -> LegacyImportPlan {
var catalog = existingDocument.catalog
let now = Date()
let groupName = nextGroupName(in: catalog)
let groupID = try catalog.addGroup(groupName)
let serverImport = try importServers(snapshot.profiles, into: &catalog, groupID: groupID)
let groupID = try catalog.addGroup(groupName, now: now)
let serverImport = try importServers(
snapshot.profiles, into: &catalog, groupID: groupID, now: now)
let report = LegacyImportReport(
groupName: groupName,
importedServerCount: serverImport.importedServerCount,
@@ -227,7 +229,8 @@ extension LegacyImportPlanner {
private static func importServers(
_ profiles: [LegacyServerSnapshot],
into catalog: inout ConfigurationCatalog,
groupID: NodeID
groupID: NodeID,
now: Date
) throws -> ServerImportResult {
var credentials: [CredentialReference: String] = [:]
var skippedRecords: [LegacySkippedRecord] = []
@@ -260,7 +263,7 @@ extension LegacyImportPlanner {
remark: profile.remark ?? "",
pluginProgram: profile.plugin?.isEmpty == false ? profile.plugin : nil,
pluginOptionsRef: pluginOptionsRef)
try catalog.addServer(fields, id: nodeID, to: groupID)
try catalog.addServer(fields, id: nodeID, to: groupID, now: now)
importedServerCount += 1
}
}
@@ -140,7 +140,7 @@ final class CatalogFileStoreTests: XCTestCase {
"订阅 URL 明文不得落盘(D5:只存凭据引用)")
}
func testV3FailureReasonMigratesToLegacyAndV4SaveRemovesReason() throws {
func testV3FailureReasonMigratesToLegacyAndNextSaveRemovesReason() throws {
let fixture = try CatalogFixtures.makeSubscriptionFixture(prefix: "migration")
let record = SubscriptionRecord(
id: NodeID(rawValue: "migration:source"),
@@ -173,7 +173,7 @@ final class CatalogFileStoreTests: XCTestCase {
try store.save(loaded)
let raw = try String(contentsOf: store.fileURL, encoding: .utf8)
XCTAssertTrue(raw.contains("\"version\" : 4"))
XCTAssertTrue(raw.contains("\"version\" : 5"))
XCTAssertFalse(raw.contains("\"reason\""))
XCTAssertFalse(raw.contains("provider.example"))
}
@@ -190,7 +190,7 @@ final class CatalogFileStoreTests: XCTestCase {
XCTAssertTrue(loaded.catalog.isEmpty)
}
func testLegacyEnabledFieldIsIgnoredAndNextSaveWritesV4WithoutIt() throws {
func testLegacyEnabledFieldIsIgnoredAndNextSaveWritesCurrentVersionWithoutIt() throws {
let payload = """
{"version": 1, "rootChildren": ["g"], "entries": [
{"id": "g", "source": "manual", "enabled": false,
@@ -203,8 +203,49 @@ final class CatalogFileStoreTests: XCTestCase {
try store.save(loaded)
let raw = try String(contentsOf: store.fileURL, encoding: .utf8)
XCTAssertTrue(raw.contains("\"version\" : 4"))
XCTAssertFalse(raw.contains("\"enabled\""), "v4 不再写出节点级 enabled")
XCTAssertTrue(raw.contains("\"version\" : 5"))
XCTAssertFalse(raw.contains("\"enabled\""), "v3 起不再写出节点级 enabled")
}
// MARK: 节点时间戳(v5,ADR-0031)
func testNodeTimestampsRoundTripLosslessly() throws {
var catalog = ConfigurationCatalog()
let created = Date(timeIntervalSince1970: 1_700_000_000)
let id = try catalog.addServer(
ServerFields(
address: "198.51.100.9",
port: 8388,
encryptionMethod: "aes-256-gcm",
passwordRef: CredentialReference(rawValue: "ref-timestamps"),
remark: "时间戳"
),
id: NodeID(rawValue: "manual:timestamped"),
now: created
)
try store.save(CatalogDocument(catalog: catalog))
let loaded = try store.load()
let entry = try XCTUnwrap(loaded.catalog.entry(for: id))
XCTAssertEqual(entry.createdAt, created, "创建时间无损")
XCTAssertEqual(entry.updatedAt, created)
}
func testLegacyEntriesLoadWithUnknownTimestampsInsteadOfBackfill() throws {
let payload = """
{"version": 4, "rootChildren": ["g"], "entries": [
{"id": "g", "source": "manual",
"kind": {"group": {"_0": {"name": "G", "children": []}}}}
]}
"""
try payload.write(to: store.fileURL, atomically: true, encoding: .utf8)
let loaded = try store.load()
let group = try XCTUnwrap(loaded.catalog.entry(for: NodeID(rawValue: "g")))
XCTAssertNil(group.createdAt, "历史文档缺时间戳读取为未知,不回填")
XCTAssertNil(group.updatedAt)
}
/// 订阅记录 JSON 用真编码器生成:枚举 Codable 形状是实现细节,不在测试里手写。
@@ -0,0 +1,228 @@
import XCTest
@testable import ShadowsocksX_NG2
/// 节点时间戳语义(ADR-0031):节点自身资料变化更新自身修改时间;分组的
/// 直接子节点集合或顺序变化更新该分组(类目录修改时间);被移动节点自身
/// 不动;内容未变不更新。订阅刷新按节点内容逐项比较。
final class ConfigurationCatalogTimestampTests: XCTestCase {
private let time0 = Date(timeIntervalSince1970: 1_700_000_000)
private let time1 = Date(timeIntervalSince1970: 1_700_001_000)
private let time2 = Date(timeIntervalSince1970: 1_700_002_000)
// MARK: 手动子树
func testNewNodeStampsBothTimesAndBumpsParentGroup() throws {
var catalog = ConfigurationCatalog()
let group = try catalog.addGroup("组", now: time0)
let leaf = try catalog.addTestServer("a", to: group, now: time1)
let groupEntry = try XCTUnwrap(catalog.entry(for: group))
let leafEntry = try XCTUnwrap(catalog.entry(for: leaf))
XCTAssertEqual(groupEntry.createdAt, time0)
XCTAssertEqual(groupEntry.updatedAt, time1, "新增子节点更新落点分组修改时间")
XCTAssertEqual(leafEntry.createdAt, time1)
XCTAssertEqual(leafEntry.updatedAt, time1)
}
func testUpdateServerBumpsOnlyWhenContentChanges() throws {
var catalog = ConfigurationCatalog()
let id = try catalog.addTestServer("a", now: time0)
guard case .server(var fields)? = catalog.entry(for: id)?.kind else {
return XCTFail("应为服务器叶子")
}
fields.port = 9999
try catalog.updateServer(id, with: fields, now: time1)
XCTAssertEqual(catalog.entry(for: id)?.updatedAt, time1, "内容变化更新修改时间")
XCTAssertEqual(catalog.entry(for: id)?.createdAt, time0, "创建时间保留")
try catalog.updateServer(id, with: fields, now: time2)
XCTAssertEqual(catalog.entry(for: id)?.updatedAt, time1, "内容未变不更新修改时间")
}
func testRenameGroupBumpsOnlyWhenNameChanges() throws {
var catalog = ConfigurationCatalog()
let group = try catalog.addGroup("旧名", now: time0)
try catalog.renameGroup(group, to: "旧名", now: time1)
XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time0, "同名重命名是内容未变")
try catalog.renameGroup(group, to: "新名", now: time2)
XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time2)
XCTAssertEqual(catalog.entry(for: group)?.createdAt, time0)
}
func testMoveBumpsSourceAndTargetGroupsButNotMovedNode() throws {
var catalog = ConfigurationCatalog()
let source = try catalog.addGroup("源组", now: time0)
let target = try catalog.addGroup("目标组", now: time0)
let leaf = try catalog.addTestServer("a", to: source, now: time0)
try catalog.move(leaf, to: target, now: time1)
XCTAssertEqual(catalog.entry(for: leaf)?.updatedAt, time0, "被移动节点自身内容不变")
XCTAssertEqual(catalog.entry(for: source)?.updatedAt, time1, "原分组失去子节点")
XCTAssertEqual(catalog.entry(for: target)?.updatedAt, time1, "目标分组获得子节点")
}
func testReorderBumpsParentOnly() throws {
var catalog = ConfigurationCatalog()
let group = try catalog.addGroup("组", now: time0)
let first = try catalog.addTestServer("a", to: group, now: time0)
let second = try catalog.addTestServer("b", to: group, now: time0)
try catalog.move(second, to: group, index: 0, now: time1)
XCTAssertEqual(try catalog.children(of: group), [second, first])
XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time1, "直接子序变化更新分组")
XCTAssertEqual(catalog.entry(for: first)?.updatedAt, time0)
XCTAssertEqual(catalog.entry(for: second)?.updatedAt, time0)
}
func testRemoveBumpsParentGroupOfRemovedRoot() throws {
var catalog = ConfigurationCatalog()
let group = try catalog.addGroup("组", now: time0)
let leaf = try catalog.addTestServer("a", to: group, now: time0)
_ = try catalog.remove(leaf, now: time1)
XCTAssertFalse(catalog.contains(leaf))
XCTAssertEqual(catalog.entry(for: group)?.updatedAt, time1, "分组失去子节点")
}
func testRootLevelChangesDoNotTouchAnyTimestamp() throws {
var catalog = ConfigurationCatalog()
let first = try catalog.addTestServer("a", now: time0)
let second = try catalog.addTestServer("b", now: time1)
_ = try catalog.remove(first, now: time2)
XCTAssertEqual(catalog.entry(for: second)?.updatedAt, time1, "根层结构变化无分组可更新")
}
// MARK: 订阅快照刷新
private let groupID = NodeID(rawValue: "t:group")
private let nestedID = NodeID(rawValue: "t:nested")
private let leafA = NodeID(rawValue: "t:a")
private let leafB = NodeID(rawValue: "t:b")
private func fields(remark: String, port: Int = 8388) -> ServerFields {
ServerFields(
address: "203.0.113.7",
port: port,
encryptionMethod: "aes-256-gcm",
passwordRef: CredentialReference(rawValue: "ref-\(remark)"),
remark: remark
)
}
/// 固定分组(嵌套分组 + 两个服务器叶子)与快照同构的目录夹具。
private func makeSubscriptionCatalog() throws -> ConfigurationCatalog {
var catalog = ConfigurationCatalog()
try catalog.addGroup("订阅分组", source: .subscription, id: groupID, now: time0)
try catalog.addGroup("嵌套分组", source: .subscription, id: nestedID, to: groupID, now: time0)
try catalog.addServer(
fields(remark: "a"), source: .subscription, id: leafA, to: groupID, now: time0)
try catalog.addServer(
fields(remark: "b"), source: .subscription, id: leafB, to: nestedID, now: time0)
return catalog
}
/// 与夹具同构的快照(固定分组 = 嵌套分组 + 服务器 a;嵌套 = 服务器 b),
/// 各参数可单独改写以制造远端变化。
private func snapshot(
name: String = "订阅分组",
nestedName: String = "嵌套分组",
nestedChildren: [CatalogSubscriptionSnapshot.Child]
) -> CatalogSubscriptionSnapshot {
let nested = CatalogSubscriptionSnapshot.Group(
id: nestedID, name: nestedName, children: nestedChildren)
return CatalogSubscriptionSnapshot(
name: name,
root: CatalogSubscriptionSnapshot.Group(
id: NodeID(rawValue: "t:remote-root"), name: name,
children: [
CatalogSubscriptionSnapshot.Child.group(nested),
leaf(leafA, fields(remark: "a")),
]))
}
private func leaf(
_ id: NodeID, _ leafFields: ServerFields
) -> CatalogSubscriptionSnapshot.Child {
.server(.init(id: id, fields: leafFields))
}
func testIdenticalSnapshotRefreshKeepsEveryTimestamp() throws {
var catalog = try makeSubscriptionCatalog()
let before = catalog
let snap = snapshot(nestedChildren: [leaf(leafB, fields(remark: "b"))])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertEqual(catalog, before, "内容未变的刷新不动任何节点的时间戳")
}
func testChangedLeafBumpsOnlyThatLeaf() throws {
var catalog = try makeSubscriptionCatalog()
let snap = snapshot(nestedChildren: [leaf(leafB, fields(remark: "b", port: 9999))])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertEqual(catalog.entry(for: leafB)?.updatedAt, time1, "内容变化的叶子更新修改时间")
XCTAssertEqual(catalog.entry(for: leafB)?.createdAt, time0, "延续叶子保留创建时间")
XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time0, "嵌套分组名称与直接子序未变")
XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组名称与直接子序未变")
}
func testNewLeafStampsFreshTimesAndBumpsItsParentGroup() throws {
var catalog = try makeSubscriptionCatalog()
let leafC = NodeID(rawValue: "t:c")
let snap = snapshot(
nestedChildren: [leaf(leafB, fields(remark: "b")), leaf(leafC, fields(remark: "c"))])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertEqual(catalog.entry(for: leafC)?.createdAt, time1)
XCTAssertEqual(catalog.entry(for: leafC)?.updatedAt, time1)
XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1, "嵌套分组直接子节点集合变化")
XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组直接子序未变")
}
func testRemoteRenameBumpsOnlyRenamedGroup() throws {
var catalog = try makeSubscriptionCatalog()
let snap = snapshot(
nestedName: "远端新名", nestedChildren: [leaf(leafB, fields(remark: "b"))])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1)
XCTAssertEqual(catalog.entry(for: leafB)?.updatedAt, time0)
XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0, "固定分组自身名称未变")
}
func testRemoteFixedGroupNameChangeBumpsFixedGroup() throws {
var catalog = try makeSubscriptionCatalog()
let snap = snapshot(
name: "远端新订阅名", nestedChildren: [leaf(leafB, fields(remark: "b"))])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time1)
XCTAssertEqual(catalog.entry(for: groupID)?.createdAt, time0, "固定分组身份客户端所有,创建时间保留")
XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time0)
}
func testRemovedLeafBumpsItsParentGroup() throws {
var catalog = try makeSubscriptionCatalog()
let snap = snapshot(nestedChildren: [])
_ = try catalog.applySubscriptionSnapshot(snap, into: groupID, now: time1)
XCTAssertFalse(catalog.contains(leafB))
XCTAssertEqual(catalog.entry(for: nestedID)?.updatedAt, time1, "子节点被远端移除")
XCTAssertEqual(catalog.entry(for: groupID)?.updatedAt, time0)
}
}
@@ -78,10 +78,12 @@ extension ConfigurationCatalog {
source: NodeSource = .manual,
id: NodeID? = nil,
to parent: NodeID? = nil,
index: Int? = nil
index: Int? = nil,
now: Date = Date()
) throws -> NodeID {
try addServer(
CatalogFixtures.serverFields(remark: remark), source: source, id: id, to: parent, index: index
CatalogFixtures.serverFields(remark: remark), source: source, id: id, to: parent,
index: index, now: now
)
}
}
@@ -0,0 +1,17 @@
# 配置目录节点记录创建时间与修改时间
Status: Accepted (2026-10-09)
配置目录的每个节点(服务器配置与配置组)增加创建时间与修改时间事实,目录文档版本升至 v5(读取兼容 v1–v5,写入始终 v5)。修改时间采用结构敏感语义(类比目录 mtime):节点自身资料变化(服务器的连接字段与名称、分组的名称)更新自身;分组的直接子节点集合或顺序变化(增删子节点、子节点移入移出、重排)更新该分组;被移动节点自身内容不变、修改时间不动;内容未变的写操作(同名重命名、同值字段提交、内容未变的订阅刷新)不更新任何时间戳。时间一律由调用方传入(`now: Date`),领域层保持无时钟纯值类型;一次逻辑变更(导入、复制、订阅快照应用)共享同一时刻。
订阅子树同样维护时间戳:订阅刷新以快照整体重建,但逐节点与旧载荷比较——延续且内容未变的节点保留双时间戳,内容变化的保留创建时间、更新修改时间,新出现的节点双时间戳为写入时刻;固定分组身份客户端所有,其创建时间跨刷新保留。复制、URI/文件导入与 Legacy 导入产生的新节点,创建时间即操作时刻,与「全新本地身份」规则一致(ADR-0027)。历史文档(v1–v4)没有时间戳,读取为未知(nil)且不回填,与订阅 `lastSucceededAt` 的「旧文件缺失时不猜测」原则一致。
## Considered options
- 最窄语义(仅节点自身资料变化才更新,结构变化完全不动时间戳):实现最简,但把服务器移入/移出分组、组内重排不会在任何节点留下痕迹,与「分组内容被改过」的用户直觉不符。被否。
- 写入即更新(不比较内容,任何写操作都刷新相关节点):无需比较逻辑,但内容未变的订阅刷新会刷新整棵子树的修改时间,同名重命名与同值保存也会伪造变化,事实失真。被否。
## Consequences
- v5 文档中未知的时间戳省略键;`CatalogEntry` 的 `Equatable` 包含时间戳,跨时间构造的目录夹具做整体相等比较时必须用受控时刻。
- 展示 UI 不在本次范围内;后续直接读取节点的创建/修改时间即可,语义以本 ADR 与 GLOSSARY「节点时间戳」为准。