-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/#1 - Swift Data 모델링 및 AppSchema 구현 #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
e25c3c1
0c0e53b
9ab3eff
b315d60
adbede5
8bfe9b2
9b4c466
d9d6081
ece1f0a
5a15217
e1bbd2d
8874a53
eb3061b
d8a75db
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| } |
| 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) | ||
|
|
||
| static var appSchema: Schema { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이렇게 도메인이 한정적인 필드의 경우는 enum + String 저장 형태로 바꾸는 것이 더 안전해보입니다. RawRepresentable 참고
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| } | ||
| 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 = [] | ||
| } | ||
| } | ||
| } |
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. secretId만 id가 아닌 네이밍으로 지은 이유가 있나요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.swiftRepository: 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)"
doneRepository: DevaultProject/Devault-macOS Length of output: 1581 모델 선언부에 접근 제어자를 명시해 주세요.
적용 예시 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(),
...
) {
...
}
}
}같은 패턴이 🤖 Prompt for AI Agents |
||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SecretAuditLog.secret이 non-optional이라 Secret에서 deleteRule: .nullify 옵션 사용할 경우에 런타임 충돌이나 저장 실패 가능해집니다.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. .nullify의 의도는 "보안/감시 로그는 Secret 삭제시에도 보존해야 한다"로 결정했으므로 옵셔널로 수정하겠습니다! 다만 Secret 삭제시, 관계가 끊기기 때문에 어떤 Secret에 대한 로그였는지 알기 위한 스냅샷 필드 추가 고려중인데 의견이 궁금합니다!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 스냅샷 필드 추가 좋아용. 최소한 식별용으로 secretId(UUID), secretName, secretType 정도가 포함되면 좋을 것 같네요.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
| ) { | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win 모든 선언부에 접근 제어자(access control) 명시 필요 클래스 자체( ✏️ 개선 예시 (의도한 접근 수준에 맞게 조정) 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 |
||
| } | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 링크 자체가 직접 삭제될 때의 동작이 정의되어 있지 않고, 양쪽이 non-optional이라 단독 삭제 시 무결성이 깨질 수 있습니다. Project/Secret 쪽 cascade에 의해서만 삭제되도록 옵셔널 처리 강화가 필요해보입니다.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Project/Secret 쪽 cascade에 의해서만 삭제되는 것이 아닌 Project에서 Secret을 제거하는 경우도 고려해서 설계하였습니다! 이 동작은 Secret 삭제가 아니라 Project-Secret 연결 해제로 정의했는데, 이때 실제로 삭제되는 것은
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 상세한 설계 의도 설명 감사합니다 🙏 |
||
|
|
||
| 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 | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
actual의 의미가 좀 모호한 것 같아요. currentVersion 또는 schemaVersion 같은 네이밍이 좋을 것 같아요.(이건 취향차이긴 함)