Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
cfb1ae6
[#12] bugfix: Secret 생성자 오류 수정
dlguszoo May 23, 2026
5782af9
[#12] feat: 모듈간 dependency 설정
dlguszoo May 23, 2026
8f4d12e
[#12] feat: Secret, Metadata, Payload entity 구현
dlguszoo May 23, 2026
b321a55
[#12] delete: DVDomain project 파일 내의 test 삭제
dlguszoo May 23, 2026
a1c20f1
[#12] feat: SecretRepository, Error case 구현
dlguszoo May 23, 2026
b946857
[#12] feat: SecretPatch, SecretQuery 구현
dlguszoo May 23, 2026
64e2e6f
[#12] feat: SecretCryptoService 포로토콜 및 Error 구현
dlguszoo May 23, 2026
3169653
[#12] fix: SecretMetadata, Payload Entity에서 id 제거
dlguszoo May 23, 2026
6ecf9d7
[#12] feat: PayloadData 프로토콜 및 secret 종류별 구현
dlguszoo May 23, 2026
7b0f66a
[#12] feat: Metadata 프로토콜 및 Secret 유형별 구현
dlguszoo May 23, 2026
ebd2a64
[#12] feat: Secret (생성, 삭제, 조회, 수정) 관련 UseCase 인터페이스 정의
dlguszoo May 23, 2026
a967188
[#12] feat: Secret UseCase 구현
dlguszoo May 23, 2026
5c4638c
[#12] feat: Secret create에 필요한 input 정보 표현 draft 구현
dlguszoo May 23, 2026
5b4ffee
[#12] feat: SecretUseCaseError 정의 및 mapping 함수 구현
dlguszoo May 23, 2026
cc06960
[#12] feat: Secret UseCase에서의 공통 로직 helper 구현
dlguszoo May 23, 2026
7a18bfb
[#12] feat: Secret, SecretPayload, SecretMetadata에 extension으로 Entity…
dlguszoo May 23, 2026
99e0136
[#12] feat: modelContainer 구성하는 LocalStorage 구현
dlguszoo May 23, 2026
8f0fd93
[#12] feat: 기본 Secret(payload, metadata 포함) CRUD 메서드 구현한 SecretReposi…
dlguszoo May 23, 2026
341e9a4
[#12] feat: SecretFetchDescriptorBuilder, InMemorySecretQueryFilter 구현
dlguszoo May 23, 2026
aec73b7
[#12] chore: Domain 모듈 Service 재폴더링
dlguszoo May 23, 2026
582794c
[#12] feat: payload, metadata JSONCoder 구현
dlguszoo May 23, 2026
4c2c8a2
[#12] feat: SecretCryptoServiceImpl 구현
dlguszoo May 23, 2026
e61f60b
[#12] feat: 키체인 key 접근 메서드 담은 KeychainKeyStore 구현
dlguszoo May 23, 2026
f1ea881
[#12] fix: SecretRepositoryImpl에서 public 생성자 제거
dlguszoo May 23, 2026
6e619be
[#12] feat: Secret 생성/조회 demo view 구현
dlguszoo May 23, 2026
d6596d6
[#12] feat: UserAuthenticationService protocol, 구현체, error 구현
dlguszoo May 23, 2026
6de4ec8
[#12] feat: payload reveal시 authenticate 메서드 거침
dlguszoo May 23, 2026
137a984
[#12] refactor: KeychainKeyStore 중복 코드 처리 및. keychainFailure 에러케이스 추가
dlguszoo May 23, 2026
e69a414
[#12] feat: ContentView의 SecretUseCaseDemoView에서 받는 useCase에 authenti…
dlguszoo May 23, 2026
9ab75e8
[#12] fix: 인증 후 조회/복호화 순서 조정
dlguszoo May 23, 2026
aefd684
[#12] bugfix: Secret 생성자 오류 수정
dlguszoo May 23, 2026
e80a794
[#12] feat: 모듈간 dependency 설정
dlguszoo May 23, 2026
8e705d8
[#12] feat: Secret, Metadata, Payload entity 구현
dlguszoo May 23, 2026
35ee0fc
[#12] delete: DVDomain project 파일 내의 test 삭제
dlguszoo May 23, 2026
8b5f79f
[#12] feat: SecretRepository, Error case 구현
dlguszoo May 23, 2026
e79977c
[#12] feat: SecretPatch, SecretQuery 구현
dlguszoo May 23, 2026
f478bcf
[#12] feat: SecretCryptoService 포로토콜 및 Error 구현
dlguszoo May 23, 2026
bd76527
[#12] fix: SecretMetadata, Payload Entity에서 id 제거
dlguszoo May 23, 2026
b2dfd41
[#12] feat: PayloadData 프로토콜 및 secret 종류별 구현
dlguszoo May 23, 2026
2d3a775
[#12] feat: Metadata 프로토콜 및 Secret 유형별 구현
dlguszoo May 23, 2026
8601c2c
[#12] feat: Secret (생성, 삭제, 조회, 수정) 관련 UseCase 인터페이스 정의
dlguszoo May 23, 2026
0003d5f
[#12] feat: Secret UseCase 구현
dlguszoo May 23, 2026
6e51543
[#12] feat: Secret create에 필요한 input 정보 표현 draft 구현
dlguszoo May 23, 2026
da4158f
[#12] feat: SecretUseCaseError 정의 및 mapping 함수 구현
dlguszoo May 23, 2026
8aa720d
[#12] feat: Secret UseCase에서의 공통 로직 helper 구현
dlguszoo May 23, 2026
aa24bf0
[#12] feat: Secret, SecretPayload, SecretMetadata에 extension으로 Entity…
dlguszoo May 23, 2026
8b627c3
[#12] feat: modelContainer 구성하는 LocalStorage 구현
dlguszoo May 23, 2026
c24fa5c
[#12] feat: 기본 Secret(payload, metadata 포함) CRUD 메서드 구현한 SecretReposi…
dlguszoo May 23, 2026
4114192
[#12] feat: SecretFetchDescriptorBuilder, InMemorySecretQueryFilter 구현
dlguszoo May 23, 2026
21c8ad5
[#12] chore: Domain 모듈 Service 재폴더링
dlguszoo May 23, 2026
fbdbcad
[#12] feat: payload, metadata JSONCoder 구현
dlguszoo May 23, 2026
9950a06
[#12] feat: SecretCryptoServiceImpl 구현
dlguszoo May 23, 2026
49b1547
[#12] feat: 키체인 key 접근 메서드 담은 KeychainKeyStore 구현
dlguszoo May 23, 2026
ae20f04
[#12] fix: SecretRepositoryImpl에서 public 생성자 제거
dlguszoo May 23, 2026
5edb35f
[#12] feat: Secret 생성/조회 demo view 구현
dlguszoo May 23, 2026
ef5a452
[#12] feat: UserAuthenticationService protocol, 구현체, error 구현
dlguszoo May 23, 2026
5b800ba
[#12] feat: payload reveal시 authenticate 메서드 거침
dlguszoo May 23, 2026
7091064
[#12] refactor: KeychainKeyStore 중복 코드 처리 및. keychainFailure 에러케이스 추가
dlguszoo May 23, 2026
5c87dea
[#12] feat: ContentView의 SecretUseCaseDemoView에서 받는 useCase에 authenti…
dlguszoo May 23, 2026
9faeab4
[#12] fix: 인증 후 조회/복호화 순서 조정
dlguszoo May 23, 2026
96b2414
Merge branch 'feature/#12/Secret_ORM' of https://github.com/DevaultPr…
dlguszoo May 26, 2026
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
6 changes: 5 additions & 1 deletion Projects/DVData/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ let project = Project.project(
.target(
name: DVModule.DVData.name,
product: Project.product,
sources: .sources
sources: .sources,
dependencies: [
.domain(),
.core(),
]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright © 2026 Devault. All rights reserved

import DVDomain
import Foundation

/// SwiftData FetchDescriptor로 처리하기 애매한 조건을 Domain Secret 배열에서 후처리하는 필터. 현재는 searchText만 담당
enum InMemorySecretQueryFilter {
static func apply(_ query: SecretQuery, to secrets: [DVDomain.Secret]) -> [DVDomain.Secret] {
secrets.filter { matchesSearchText(query.searchText, secret: $0) }
}

private static func matchesSearchText(_ searchText: String?, secret: DVDomain.Secret) -> Bool {
guard let searchText else {
return true
}

let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else {
return true
}

let fields = [
secret.name,
secret.secretType,
secret.subType,
secret.service,
secret.environment,
secret.memo,
]

return fields.contains {
$0?.localizedCaseInsensitiveContains(keyword) == true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright © 2026 Devault. All rights reserved

import DVDomain
import Foundation
import SwiftData

/// Domain의 SecretQuery를 SwiftData의 FetchDescriptor<SwiftDataModel.Secret>로 바꾸는 타입
enum SecretFetchDescriptorBuilder {
static func make(from query: SecretQuery) -> FetchDescriptor<SwiftDataModel.Secret> {
var descriptor = FetchDescriptor<SwiftDataModel.Secret>(
predicate: predicate(from: query),
sortBy: sortDescriptors(from: query.sort)
)
descriptor.includePendingChanges = true
return descriptor
}

private static func predicate(from query: SecretQuery) -> Predicate<SwiftDataModel.Secret> {
let hasSecretType = !(query.secretType?.isEmpty ?? true)
let secretType = query.secretType ?? ""
let hasService = !(query.service?.isEmpty ?? true)
let service = query.service ?? ""
let hasEnvironment = !(query.environment?.isEmpty ?? true)
let environment = query.environment ?? ""

switch query.collection {
case .all:
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt == nil &&
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
}
case .liked:
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt == nil &&
secret.liked &&
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
}
case let .expired(referenceDate):
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt == nil &&
secret.expiresAt != nil &&
secret.expiresAt! < referenceDate &&
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
}
case .deleted:
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt != nil &&
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
}
case let .project(projectID):
return #Predicate<SwiftDataModel.Secret> { secret in
secret.deletedAt == nil &&
secret.projectLinks.contains { link in
link.project.id == projectID
} &&
(!hasSecretType || secret.secretType == secretType) &&
(!hasService || secret.service == service) &&
(!hasEnvironment || secret.environment == environment)
}
}
}

private static func sortDescriptors(
from sort: SecretQuery.Sort
) -> [SortDescriptor<SwiftDataModel.Secret>] {
switch sort {
case .recentlyAdded:
return [
SortDescriptor(\.createdAt, order: .reverse),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .oldestFirst:
return [
SortDescriptor(\.createdAt, order: .forward),
SortDescriptor(\.updatedAt, order: .forward),
]
case .expiringSoon:
return [
SortDescriptor(\.expiresAt, order: .forward),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .nameAscending:
return [
SortDescriptor(\.name, order: .forward),
SortDescriptor(\.updatedAt, order: .reverse),
]
case .nameDescending:
return [
SortDescriptor(\.name, order: .reverse),
SortDescriptor(\.updatedAt, order: .reverse),
]
}
}
}
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.

SecretRepositoryImpl.create에서 duplicate ID 검사 누락 [MEDIUM] (SecretRepositoryImpl.swift:181-227)

SecretRepositoryError.duplicateID(id:)가 정의되어 있지만 어디서도 throw 안 합니다. 호출자(또는 재시도 로직)가 같은 ID로 두 번 create 하면 SwiftData가 묵묵히 두 row를 만들거나 비특정 오류를 던질 수 있어요.

// create 진입부에 추가 권장
if try fetchLocalSecret(id: secret.id) != nil {
    throw SecretRepositoryError.duplicateID(id: secret.id)
}

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

import DVDomain
import Foundation
import SwiftData

@ModelActor
public actor SecretRepositoryImpl: SecretRepository {
public func create(_ secret: DVDomain.Secret) async throws -> DVDomain.Secret {
do {
let localSecret = SwiftDataModel.Secret(
id: secret.id,
name: secret.name,
secretType: secret.secretType,
subType: secret.subType,
service: secret.service,
environment: secret.environment,
expiresAt: secret.expiresAt,
memo: secret.memo,
liked: secret.liked,
deletedAt: secret.deletedAt,
createdAt: secret.createdAt,
updatedAt: secret.updatedAt
)

let localPayload = SwiftDataModel.SecretPayload(
encryptedData: secret.payload.encryptedData,
keyTag: secret.payload.keyTag,
schemaVersion: secret.payload.schemaVersion,
secret: localSecret
)
localSecret.payload = localPayload

modelContext.insert(localSecret)
modelContext.insert(localPayload)

if let metadata = secret.metadata {
let localMetadata = SwiftDataModel.SecretMetadata(
metadataJSON: metadata.metadataJSON,
schemaVersion: metadata.schemaVersion,
secret: localSecret
)
localSecret.metadata = localMetadata
modelContext.insert(localMetadata)
}

try modelContext.save()

return try localSecret.toDomain()
} catch let error as SecretRepositoryError {
throw error
} catch {
throw SecretRepositoryError.persistenceFailed
}
}

public func fetch(id: UUID) async throws -> DVDomain.Secret? {
do {
guard let localSecret = try fetchLocalSecret(id: id) else {
return nil
}
return try localSecret.toDomain()
} catch let error as SecretRepositoryError {
throw error
} catch {
throw SecretRepositoryError.persistenceFailed
}
}

/// SecretFetchDescriptorBuilder로 원하는 조건으로 fetch 후 InMemorySecretQueryFilter로 searchText 보정
public func fetch(_ query: SecretQuery) async throws -> [DVDomain.Secret] {
do {
let descriptor = SecretFetchDescriptorBuilder.make(from: query)
let localSecrets = try modelContext.fetch(descriptor)
let domainSecrets = try localSecrets.map { try $0.toDomain() }
return InMemorySecretQueryFilter.apply(query, to: domainSecrets)
} catch let error as SecretRepositoryError {
throw error
} catch {
throw SecretRepositoryError.persistenceFailed
}
}

/// SecretPatch 적용하여 update
public func patch(id: UUID, with patch: SecretPatch) async throws -> DVDomain.Secret {
do {
guard let localSecret = try fetchLocalSecret(id: id) else {
throw SecretRepositoryError.notFound(id: id)
}

apply(patch, to: localSecret)
try modelContext.save()

return try localSecret.toDomain()
} catch let error as SecretRepositoryError {
throw error
} catch {
throw SecretRepositoryError.persistenceFailed
}
}

public func delete(id: UUID) async throws {
do {
guard let localSecret = try fetchLocalSecret(id: id) else {
throw SecretRepositoryError.notFound(id: id)
}

modelContext.delete(localSecret)
try modelContext.save()
} catch let error as SecretRepositoryError {
throw error
} catch {
throw SecretRepositoryError.persistenceFailed
}
}
}

extension SecretRepositoryImpl {
private func fetchLocalSecret(id: UUID) throws -> SwiftDataModel.Secret? {
var descriptor = FetchDescriptor<SwiftDataModel.Secret>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
return try modelContext.fetch(descriptor).first
}

/// SecretPatch를 SwiftData model에 반영
private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) {
if case let .set(name) = patch.name {
secret.name = name
}
if case let .set(secretType) = patch.secretType {
secret.secretType = secretType
}
if case let .set(subType) = patch.subType {
secret.subType = subType
}
if case let .set(service) = patch.service {
secret.service = service
}
if case let .set(environment) = patch.environment {
secret.environment = environment
}
if case let .set(expiresAt) = patch.expiresAt {
secret.expiresAt = expiresAt
}
if case let .set(memo) = patch.memo {
secret.memo = memo
}
if case let .set(liked) = patch.liked {
secret.liked = liked
}
if case let .set(deletedAt) = patch.deletedAt {
secret.deletedAt = deletedAt
}
if case let .set(updatedAt) = patch.updatedAt {
secret.updatedAt = updatedAt
}
if case let .set(payload) = patch.payload {
apply(payload, to: secret)
}
if case let .set(metadata) = patch.metadata {
apply(metadata, to: secret)
}
}

/// payload가 있으면 업데이트하고, 없으면 새 payload를 만든다.
private func apply(_ payload: DVDomain.SecretPayload, to secret: SwiftDataModel.Secret) {
if let localPayload = secret.payload {
localPayload.encryptedData = payload.encryptedData
localPayload.keyTag = payload.keyTag
localPayload.schemaVersion = payload.schemaVersion
} else {
let localPayload = SwiftDataModel.SecretPayload(
encryptedData: payload.encryptedData,
keyTag: payload.keyTag,
schemaVersion: payload.schemaVersion,
secret: secret
)
secret.payload = localPayload
modelContext.insert(localPayload)
}
}

/// metadata가 nil이면 기존 metadata를 삭제하고, 값이 있으면 업데이트 또는 생성한다.
private func apply(_ metadata: DVDomain.SecretMetadata?, to secret: SwiftDataModel.Secret) {
guard let metadata else {
if let localMetadata = secret.metadata {
modelContext.delete(localMetadata)
}
secret.metadata = nil
return
}

if let localMetadata = secret.metadata {
localMetadata.metadataJSON = metadata.metadataJSON
localMetadata.schemaVersion = metadata.schemaVersion
} else {
let localMetadata = SwiftDataModel.SecretMetadata(
metadataJSON: metadata.metadataJSON,
schemaVersion: metadata.schemaVersion,
secret: secret
)
secret.metadata = localMetadata
modelContext.insert(localMetadata)
}
}
}
Loading