Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,7 @@ enable_free_tier: true
# 프로젝트 성격에 맞게 자유롭게 수정
# =============================================================================
tone_instructions: |
당신은 경험 많은 Apple 플랫폼 개발자이자 코드 리뷰어입니다.
목표는 팀원들이 더 나은 Swift 코드를 작성하며 함께 성장하도록 돕는 것입니다.

리뷰 원칙:
1. 피드백은 명확하고 구체적이어야 하며, 문제의 원인과 개선 방법을 반드시 함께 제시하세요.
2. 리뷰는 교육적이어야 하며, Apple Developer Documentation이나 Swift Evolution 제안 등 관련 자료를 함께 안내하세요.
3. 비판보다는 개선 중심의 제안을 우선하세요. "이렇게 하면 안 돼요" 대신 "이렇게 하면 더 좋아요"로 표현하세요.
4. 잘된 부분은 짧고 위트 있게 칭찬하세요.
5. 스타일 지적은 SwiftLint/SwiftFormat으로 자동화할 수 있는 것인지 구분하고, 자동화 가능한 경우 툴 도입을 제안하세요.
경험 많은 Apple 플랫폼 개발자로서 한국어로 리뷰하세요. 문제의 원인과 개선 방법을 구체적으로 제안하고, Swift/Apple 문서 근거를 덧붙이세요. 비판보다 개선 중심으로 말하며, 자동화 가능한 스타일 지적은 SwiftLint/SwiftFormat 도입을 제안하세요.

# =============================================================================
# 리뷰 설정
Expand Down
25 changes: 25 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/AppAuditLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class AppAuditLog {
@Attribute(.unique) var id: UUID
var eventType: String
var actorContext: String
var occurredAt: Date

init(
id: UUID = UUID(),
eventType: String,
actorContext: String,
occurredAt: Date = Date()
) {
self.id = id
self.eventType = eventType
self.actorContext = actorContext
self.occurredAt = occurredAt
}
}
}
22 changes: 22 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/AppSchema.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright © 2026 Devault. All rights reserved

import SwiftData

enum SwiftDataModel { }

extension Schema {
private static let actualVersion: Schema.Version = Version(1, 0, 0)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actual의 의미가 좀 모호한 것 같아요. currentVersion 또는 schemaVersion 같은 네이밍이 좋을 것 같아요.(이건 취향차이긴 함)


static var appSchema: Schema {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appSchema는 호출 시마다 Schema를 생성하는 것 같은데 캐싱하면 어떨까요?

Schema([
SwiftDataModel.Project.self,
SwiftDataModel.Secret.self,
SwiftDataModel.SecretProjectLink.self,
SwiftDataModel.SecretPayload.self,
SwiftDataModel.SecretMetadata.self,
SwiftDataModel.SecretAuditLog.self,
SwiftDataModel.AppAuditLog.self,
SwiftDataModel.BackupRecord.self,
], version: actualVersion)
}
}
37 changes: 37 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/BackupRecord.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class BackupRecord {
@Attribute(.unique) var id: UUID
var fileName: String
var filePath: String
var backupScope: String
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 도메인이 한정적인 필드의 경우는 enum + String 저장 형태로 바꾸는 것이 더 안전해보입니다. RawRepresentable 참고

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extension SwiftDataModel {
    enum BackupScope: String, Codable, CaseIterable, Sendable {
        case optionA
        case optionB
        case optionC

        var displayName: String {
            switch self {
            case .optionA: "옵션 A"
            case .optionB: "옵션 B"
            case .optionC: "옵션 C"
            }
        }
    }
}

var hasIndependentPassword: Bool
var keyTag: String
var totalSecrets: Int
var createdAt: Date

init(
id: UUID = UUID(),
fileName: String,
filePath: String,
backupScope: String,
hasIndependentPassword: Bool,
keyTag: String,
totalSecrets: Int,
createdAt: Date = Date()
) {
self.id = id
self.fileName = fileName
self.filePath = filePath
self.backupScope = backupScope
self.hasIndependentPassword = hasIndependentPassword
self.keyTag = keyTag
self.totalSecrets = totalSecrets
self.createdAt = createdAt
}
}
}
34 changes: 34 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class Project {
@Attribute(.unique) var id: UUID
var name: String
var createdAt: Date
var updatedAt: Date

@Relationship(deleteRule: .cascade, inverse: \SwiftDataModel.SecretProjectLink.project)
var secretLinks: [SecretProjectLink]

/// `SecretProjectLink`를 통해 연결된 시크릿 목록을 반환하는 편의 접근자입니다.
var secrets: [Secret] {
secretLinks.map(\.secret)
}

init(
id: UUID = UUID(),
name: String,
createdAt: Date = Date(),
updatedAt: Date = Date()
) {
self.id = id
self.name = name
self.createdAt = createdAt
self.updatedAt = updatedAt
self.secretLinks = []
}
}
}
71 changes: 71 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/Secret.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright © 2026 Devault. All rights reserved

import SwiftData
import Foundation

extension SwiftDataModel {
@Model final class Secret {
@Attribute(.unique) var secretId: UUID
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secretId만 id가 아닌 네이밍으로 지은 이유가 있나요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 수정하겠습니다~!

var name: String
var secretType: String
var subType: String?
var service: String?
var environment: String?
var expiresAt: Date?
var memo: String?
var liked: Bool
var deletedAt: Date?
var createdAt: Date
var updatedAt: Date

@Relationship(deleteRule: .cascade, inverse: \SwiftDataModel.SecretProjectLink.secret)
var projectLinks: [SecretProjectLink]

@Relationship(deleteRule: .cascade, inverse: \SwiftDataModel.SecretPayload.secret)
var payload: SecretPayload?

@Relationship(deleteRule: .cascade, inverse: \SwiftDataModel.SecretMetadata.secret)
var metadata: SecretMetadata?

@Relationship(deleteRule: .nullify, inverse: \SwiftDataModel.SecretAuditLog.secret)
var auditLogs: [SecretAuditLog]

/// `SecretProjectLink`를 통해 연결된 프로젝트 목록을 반환하는 편의 접근자입니다.
var projects: [Project] {
projectLinks.map(\.project)
}

init(
secretId: UUID = UUID(),
name: String,
secretType: String,
subType: String? = nil,
service: String? = nil,
environment: String? = nil,
expiresAt: Date? = nil,
memo: String? = nil,
liked: Bool = false,
deletedAt: Date? = nil,
createdAt: Date = Date(),
updatedAt: Date? = nil
) {
let initialCreatedAt = createdAt
self.secretId = secretId
self.name = name
self.secretType = secretType
self.subType = subType
self.service = service
self.environment = environment
self.expiresAt = expiresAt
self.memo = memo
self.liked = liked
self.deletedAt = deletedAt
self.createdAt = initialCreatedAt
self.updatedAt = updatedAt ?? initialCreatedAt
self.projectLinks = []
self.payload = nil
self.metadata = nil
self.auditLogs = []
}
}
Comment on lines +7 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 접근 제어자 미명시 선언 탐지 (수정 후 결과가 없어야 정상)
rg -nP '^\s*(?!public\b|internal\b|private\b|fileprivate\b|open\b)(`@Model`\s+)?(final\s+class\b|var\b|init\s*\()' \
  Projects/DVData/Sources/Storage/Local/Models/Secret.swift

Repository: DevaultProject/Devault-macOS

Length of output: 676


🏁 Script executed:

# 확인 차원의 추가 검증: 코드베이스에서 유사한 SwiftData 모델들의 패턴 확인
find Projects/DVData/Sources/Storage/Local/Models -name "*.swift" -type f | head -5 | while read f; do
  echo "=== $f ==="
  head -20 "$f" | grep -E "(class|var|init)"
done

Repository: DevaultProject/Devault-macOS

Length of output: 1581


모델 선언부에 접근 제어자를 명시해 주세요.

Secret 클래스와 모든 프로퍼티, projects 계산 속성, init 메서드에 접근 제어자가 생략되어 있습니다. Swift 기본값은 internal이지만, 명시적으로 선언하면 모듈 API 경계가 명확해지고 SwiftData 모델 확장 시 의도가 분명해져 유지보수가 수월해집니다. Swift 공식 Access Control 가이드에서도 이를 권장합니다.

적용 예시
 extension SwiftDataModel {
-    `@Model` final class Secret {
-        `@Attribute`(.unique) var secretId: UUID
-        var name: String
+    `@Model` internal final class Secret {
+        `@Attribute`(.unique) internal var secretId: UUID
+        internal var name: String
         ...
-        var projects: [Project] {
+        internal var projects: [Project] {
             projectLinks.compactMap(\.project)
         }

-        init(
+        internal init(
             secretId: UUID = UUID(),
             ...
         ) {
             ...
         }
     }
 }

같은 패턴이 AppAuditLog, BackupRecord, Project 모델에도 적용되므로, SwiftLint 커스텀 룰이나 SwiftFormat 설정으로 전사(rollout)하는 것을 추천합니다. 이렇게 자동화하면 향후 신규 모델도 일관되게 관리할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVData/Sources/Storage/Local/Models/Secret.swift` around lines 7 -
69, The Secret model and its members lack explicit access control; annotate the
class declaration and every stored property, the computed property projects, and
the init initializer with an explicit access level (e.g., internal or public
depending on intended API exposure) so the intent is clear (update the
declaration of Secret, all vars like secretId, name, secretType, subType,
service, environment, expiresAt, memo, liked, deletedAt, createdAt, updatedAt,
projectLinks, payload, metadata, auditLogs, the computed property projects, and
the init signature to include the chosen access modifier); apply the same
explicit-access change pattern to AppAuditLog, BackupRecord, and Project or add
a SwiftLint/SwiftFormat rule to enforce it across models.

}
33 changes: 33 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/SecretAuditLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class SecretAuditLog {
@Attribute(.unique) var id: UUID
var eventType: String
var actorContext: String
var isSuspicious: Bool
var occurredAt: Date

@Relationship
var secret: Secret
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SecretAuditLog.secret이 non-optional이라 Secret에서 deleteRule: .nullify 옵션 사용할 경우에 런타임 충돌이나 저장 실패 가능해집니다. var secret: Secret? 로 바꾸는게 맞을 것 같아요.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.nullify의 의도는 "보안/감시 로그는 Secret 삭제시에도 보존해야 한다"로 결정했으므로 옵셔널로 수정하겠습니다! 다만 Secret 삭제시, 관계가 끊기기 때문에 어떤 Secret에 대한 로그였는지 알기 위한 스냅샷 필드 추가 고려중인데 의견이 궁금합니다!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스냅샷 필드 추가 좋아용. 최소한 식별용으로 secretId(UUID), secretName, secretType 정도가 포함되면 좋을 것 같네요.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스냅샷 필드를 채우는 시점도 고민해보면 좋을 것 같은데, 일단 안전하게 로그 생성 시점에 하는 거 어떨까요?


init(
id: UUID = UUID(),
eventType: String,
actorContext: String,
isSuspicious: Bool = false,
occurredAt: Date = Date(),
secret: Secret
) {
self.id = id
self.eventType = eventType
self.actorContext = actorContext
self.isSuspicious = isSuspicious
self.occurredAt = occurredAt
self.secret = secret
}
}
}
29 changes: 29 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/SecretMetadata.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class SecretMetadata {
@Attribute(.unique) var id: UUID
@Attribute(.unique) var secretKey: String
var metadataJSON: Data
var schemaVersion: Int

@Relationship
var secret: Secret

init(
id: UUID = UUID(),
metadataJSON: Data,
schemaVersion: Int,
secret: Secret
) {
self.id = id
self.secretKey = secret.secretId.uuidString
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secret과 1:1 관계이므로 secret 관계만으로도 무결성 확보가 가능하고 secretKey는 파생값으로 보이는데, 현재 코드 기준으로는 secretKey가 초기화 시점에만 동기화되고 이후 secret.secretId가 변경돼도 자동 갱신되지 않는데 이렇게 되면 단방향 일관성만 보장됩니다.

self.metadataJSON = metadataJSON
self.schemaVersion = schemaVersion
self.secret = secret
}
}
}
32 changes: 32 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class SecretPayload {
@Attribute(.unique) var id: UUID
@Attribute(.unique) var secretKey: String
var encryptedData: Data
var keyTag: String
var schemaVersion: Int

@Relationship
var secret: Secret

init(
id: UUID = UUID(),
encryptedData: Data,
keyTag: String,
schemaVersion: Int,
secret: Secret
) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
self.id = id
self.secretKey = secret.secretId.uuidString
self.encryptedData = encryptedData
self.keyTag = keyTag
self.schemaVersion = schemaVersion
self.secret = secret
}
Comment on lines +7 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

모든 선언부에 접근 제어자(access control) 명시 필요

클래스 자체(SecretPayload)와 모든 저장 프로퍼티, init 어느 곳에도 접근 제어자가 명시되어 있지 않습니다. Swift에서 기본값은 internal이지만, 코딩 가이드라인에 따르면 각 선언부에 반드시 명시해야 합니다. DVData 모듈의 공개 API 경계를 명확히 하기 위해, 의도한 가시성(예: public/internal)을 선언에 직접 기재해주세요.

✏️ 개선 예시 (의도한 접근 수준에 맞게 조정)
 extension SwiftDataModel {
-    `@Model` final class SecretPayload {
-        `@Attribute`(.unique) var id: UUID
-        `@Attribute`(.unique) var secretKey: String
-        var encryptedData: Data
-        var keyTag: String
-        var schemaVersion: Int
+    `@Model` public final class SecretPayload {
+        `@Attribute`(.unique) public var id: UUID
+        `@Attribute`(.unique) public var secretKey: String
+        public var encryptedData: Data
+        public var keyTag: String
+        public var schemaVersion: Int

         `@Relationship`
-        var secret: Secret
+        public var secret: Secret

-        init(
+        public init(
             id: UUID = UUID(),
             encryptedData: Data,
             keyTag: String,
             schemaVersion: Int,
             secret: Secret
         ) {

As per coding guidelines, "접근 제어자(access control)가 각 선언부에 명시적으로 기재되어 있는지 확인하세요."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVData/Sources/Storage/Local/Models/SecretPayload.swift` around
lines 7 - 30, The SecretPayload type and every declaration inside it currently
lack explicit access control; add the intended visibility (e.g., public or
internal) to the class declaration, to each stored property (id, secretKey,
encryptedData, keyTag, schemaVersion, and the `@Relationship` var secret) and to
the initializer signature (init) so the module API surface is explicit—update
the declarations in SecretPayload accordingly (adjust to public/internal
consistent with DVData module API).

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright © 2026 Devault. All rights reserved

import Foundation
import SwiftData

extension SwiftDataModel {
@Model final class SecretProjectLink {
@Attribute(.unique) var linkKey: String
var linkedAt: Date

@Relationship
var project: Project

@Relationship
var secret: Secret
Comment on lines +11 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

링크 자체가 직접 삭제될 때의 동작이 정의되어 있지 않고, 양쪽이 non-optional이라 단독 삭제 시 무결성이 깨질 수 있습니다. Project/Secret 쪽 cascade에 의해서만 삭제되도록 옵셔널 처리 강화가 필요해보입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project/Secret 쪽 cascade에 의해서만 삭제되는 것이 아닌 Project에서 Secret을 제거하는 경우도 고려해서 설계하였습니다! 이 동작은 Secret 삭제가 아니라 Project-Secret 연결 해제로 정의했는데, 이때 실제로 삭제되는 것은 SecretProjectLink(Project, Secret) row뿐이고, ProjectSecret 자체는 유지됩니다. Secret이 다른 Project에도 연결되어 있다면 그 연결도 그대로 유지됩니다. 이 경우 link row 자체가 삭제되기 때문에 projectsecret을 nil로 만들 필요가 없습니다. 오히려 optional로 두면 한쪽 endpoint가 없는 invalid link row가 저장될 수 있어, join row의 양 끝점은 non-optional로 유지하는 것이 맞다고 봤습니다.
또한 Project 또는 Secret이 삭제되는 경우에도 cascade 대상은 반대편 모델이 아니라 연결된 SecretProjectLink row로 한정하려고 합니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상세한 설계 의도 설명 감사합니다 🙏
Project 또는 Secret이 삭제될 때 연결된 SecretProjectLink row가 같이 정리되도록 양쪽 모두에 deleteRule: .cascade로 설정되어야할 것 같네요 😸


init(
project: Project,
secret: Secret,
linkedAt: Date = Date()
) {
self.linkKey = "\(project.id.uuidString):\(secret.secretId.uuidString)"
self.linkedAt = linkedAt
self.project = project
self.secret = secret
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}