@horka/app-forge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +32 -0
- package/README.md +99 -0
- package/bin/cli.js +371 -0
- package/bin/cli.test.js +91 -0
- package/package.json +43 -0
- package/templates/core/CLAUDE.md +36 -0
- package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
- package/templates/core/claude/memory/COMMANDS.md +13 -0
- package/templates/core/claude/memory/DECISIONS.md +5 -0
- package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
- package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
- package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
- package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
- package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
- package/templates/core/claude/skills/save-context/SKILL.md +35 -0
- package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
- package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
- package/templates/core/docs-architecture/DELIVERY.md +68 -0
- package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
- package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
- package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
- package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
- package/templates/core/gitignore +15 -0
- package/templates/core/mcp.json +8 -0
- package/templates/packs/nuxt-web/CLAUDE.md +74 -0
- package/templates/packs/nuxt-web/app/app.vue +5 -0
- package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
- package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
- package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
- package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
- package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
- package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
- package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
- package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
- package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
- package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
- package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
- package/templates/packs/nuxt-web/gitignore +18 -0
- package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
- package/templates/packs/nuxt-web/pack.json +11 -0
- package/templates/packs/nuxt-web/package.json +31 -0
- package/templates/packs/nuxt-web/playwright.config.ts +39 -0
- package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
- package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
- package/templates/packs/nuxt-web/tsconfig.json +4 -0
- package/templates/packs/nuxt-web/vitest.config.ts +23 -0
- package/templates/packs/swift-ios/CLAUDE.md +64 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
- package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
- package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
- package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
- package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
- package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
- package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
- package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
- package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
- package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
- package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
- package/templates/packs/swift-ios/gitignore +5 -0
- package/templates/packs/swift-ios/mcp.json +8 -0
- package/templates/packs/swift-ios/pack.json +11 -0
- package/templates/packs/swift-ios/project.yml +33 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
- package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
- package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
- package/templates/packs/ts-sdk/CLAUDE.md +72 -0
- package/templates/packs/ts-sdk/MIGRATION.md +28 -0
- package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
- package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
- package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
- package/templates/packs/ts-sdk/gitignore +6 -0
- package/templates/packs/ts-sdk/pack.json +11 -0
- package/templates/packs/ts-sdk/package.json +55 -0
- package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
- package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
- package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
- package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
- package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
- package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
- package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
- package/templates/packs/ts-sdk/src/index.ts +62 -0
- package/templates/packs/ts-sdk/src/types/index.ts +33 -0
- package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
- package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
- package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
- package/templates/packs/ts-sdk/tsconfig.json +15 -0
- package/templates/packs/ts-sdk/tsup.config.ts +22 -0
- package/templates/packs/ts-sdk/vitest.config.ts +8 -0
- package/templates/packs/vapor-api/CLAUDE.md +73 -0
- package/templates/packs/vapor-api/Dockerfile +80 -0
- package/templates/packs/vapor-api/Package.swift +68 -0
- package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
- package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
- package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
- package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
- package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
- package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
- package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
- package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
- package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
- package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
- package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
- package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
- package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
- package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
- package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
- package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
- package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
- package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
- package/templates/packs/vapor-api/env_dist +29 -0
- package/templates/packs/vapor-api/gitignore +7 -0
- package/templates/packs/vapor-api/pack.json +11 -0
- package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
- package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Vapor
|
|
3
|
+
import Prometheus
|
|
4
|
+
import Metrics
|
|
5
|
+
|
|
6
|
+
/// Process-global Prometheus registry.
|
|
7
|
+
///
|
|
8
|
+
/// `MetricsSystem.bootstrap` may only run ONCE per process, while tests boot many
|
|
9
|
+
/// `Application`s — so both the registry and the bootstrap are anchored to the process
|
|
10
|
+
/// (thread-safe lazy `static let`), never to an `Application`.
|
|
11
|
+
public enum MetricsRegistry {
|
|
12
|
+
public static let shared: PrometheusCollectorRegistry = {
|
|
13
|
+
let registry = PrometheusCollectorRegistry()
|
|
14
|
+
MetricsSystem.bootstrap(PrometheusMetricsFactory(registry: registry))
|
|
15
|
+
return registry
|
|
16
|
+
}()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
extension Application {
|
|
20
|
+
/// Storage key for the Prometheus registry (set when monitoring is enabled).
|
|
21
|
+
public struct PrometheusRegistryKey: StorageKey {
|
|
22
|
+
public typealias Value = PrometheusCollectorRegistry
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Storage key for the /metrics bearer token.
|
|
26
|
+
public struct MetricsTokenKey: StorageKey {
|
|
27
|
+
public typealias Value = String
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Raised at boot when monitoring is enabled but the bearer token is missing/blank.
|
|
32
|
+
/// Surfaced as a named failure instead of a silent /metrics 401 forever (fail-fast doctrine).
|
|
33
|
+
public struct MonitoringConfigurationError: Error, CustomStringConvertible {
|
|
34
|
+
public let description: String
|
|
35
|
+
public init(_ description: String) { self.description = description }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Wire monitoring into the app. Disabled → no registry in storage → /metrics returns 503.
|
|
39
|
+
///
|
|
40
|
+
/// Fail fast: with monitoring ENABLED, an empty/blank `metricsToken` would make a
|
|
41
|
+
/// constant-time compare against "" reject every request — /metrics 401 forever, a silent
|
|
42
|
+
/// misconfiguration that contradicts the env-discipline doctrine. We refuse to boot instead.
|
|
43
|
+
public func configureMonitoring(app: Application, enabled: Bool, metricsToken: String) throws {
|
|
44
|
+
guard enabled else {
|
|
45
|
+
app.logger.info("[MONITORING] disabled")
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
guard !metricsToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
50
|
+
throw MonitoringConfigurationError(
|
|
51
|
+
"MONITORING_ENABLED is true but METRICS_TOKEN is empty — set a non-empty token in "
|
|
52
|
+
+ ".env AND env_dist AND every deploy manifest (an empty token leaves /metrics 401 "
|
|
53
|
+
+ "forever). Disable monitoring or provide the token, then re-run."
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
app.storage[Application.PrometheusRegistryKey.self] = MetricsRegistry.shared
|
|
58
|
+
app.storage[Application.MetricsTokenKey.self] = metricsToken
|
|
59
|
+
|
|
60
|
+
app.logger.info("[MONITORING] enabled — backend: swift-prometheus")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// The /metrics route handler: bearer-token gated (constant-time compare), emits the
|
|
64
|
+
/// Prometheus text format. Lives here so the App target never imports Prometheus directly.
|
|
65
|
+
public func handleMetricsRequest(_ req: Request) throws -> Response {
|
|
66
|
+
guard let registry = req.application.storage[Application.PrometheusRegistryKey.self],
|
|
67
|
+
let token = req.application.storage[Application.MetricsTokenKey.self] else {
|
|
68
|
+
throw Abort(.serviceUnavailable, reason: "Monitoring not configured")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
guard let bearer = req.headers.bearerAuthorization else {
|
|
72
|
+
throw Abort(.unauthorized, reason: "Missing authentication token")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
guard constantTimeCompare(bearer.token, token) else {
|
|
76
|
+
throw Abort(.unauthorized, reason: "Invalid authentication token")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var buffer: [UInt8] = []
|
|
80
|
+
registry.emit(into: &buffer)
|
|
81
|
+
|
|
82
|
+
var headers = HTTPHeaders()
|
|
83
|
+
headers.add(name: .contentType, value: "text/plain; version=0.0.4; charset=utf-8")
|
|
84
|
+
return Response(status: .ok, headers: headers, body: .init(string: String(decoding: buffer, as: UTF8.self)))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Increment the HTTP error counter (called by the typed-error middleware). Counter
|
|
88
|
+
/// emission is an L1 concern — feature code never touches the metrics API directly.
|
|
89
|
+
public func recordHTTPError(status: UInt, errorName: String) {
|
|
90
|
+
Counter(label: "http_errors_total",
|
|
91
|
+
dimensions: [("status", "\(status)"), ("error", errorName)]).increment()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Constant-time comparison to prevent timing attacks on the bearer token.
|
|
95
|
+
/// Always iterates over the max length of both inputs to avoid leaking length information.
|
|
96
|
+
public func constantTimeCompare(_ lhs: String, _ rhs: String) -> Bool {
|
|
97
|
+
let lhsData = Array(lhs.utf8)
|
|
98
|
+
let rhsData = Array(rhs.utf8)
|
|
99
|
+
let maxLen = max(lhsData.count, rhsData.count)
|
|
100
|
+
|
|
101
|
+
// Length mismatch must still fail, but without an early return.
|
|
102
|
+
var accumulator: UInt8 = lhsData.count == rhsData.count ? 0 : 1
|
|
103
|
+
|
|
104
|
+
for i in 0..<maxLen {
|
|
105
|
+
let lhsByte: UInt8 = i < lhsData.count ? lhsData[i] : 0
|
|
106
|
+
let rhsByte: UInt8 = i < rhsData.count ? rhsData[i] : 0
|
|
107
|
+
accumulator |= lhsByte ^ rhsByte
|
|
108
|
+
}
|
|
109
|
+
return accumulator == 0
|
|
110
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// L0 helper — primitives only. This target depends on nothing; keep it that way.
|
|
4
|
+
public extension String {
|
|
5
|
+
/// Whitespace/newline-trimmed copy. Services normalize user input with this
|
|
6
|
+
/// before persisting (storage rule: names are stored trimmed).
|
|
7
|
+
var trimmed: String {
|
|
8
|
+
trimmingCharacters(in: .whitespacesAndNewlines)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// True when the string is empty after trimming.
|
|
12
|
+
var isBlank: Bool {
|
|
13
|
+
trimmed.isEmpty
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import Testing
|
|
2
|
+
import VaporTesting
|
|
3
|
+
import Vapor
|
|
4
|
+
@testable import App
|
|
5
|
+
@testable import Monitoring
|
|
6
|
+
|
|
7
|
+
@Suite("{{PROJECT_NAME}} API — boot + contracts")
|
|
8
|
+
struct AppTests {
|
|
9
|
+
/// Boots the FULL app in `.testing` (in-memory SQLite + real migrations + real
|
|
10
|
+
/// middleware stack), runs the body, always shuts down. Each test gets a fresh app
|
|
11
|
+
/// and a fresh database — tests stay order-independent and parallel-safe.
|
|
12
|
+
private func withApp(_ body: (Application) async throws -> Void) async throws {
|
|
13
|
+
let app = try await Application.make(.testing)
|
|
14
|
+
do {
|
|
15
|
+
try await configure(app)
|
|
16
|
+
try await body(app)
|
|
17
|
+
} catch {
|
|
18
|
+
try? await app.asyncShutdown()
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
try await app.asyncShutdown()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test("GET /health responds 200")
|
|
25
|
+
func healthCheck() async throws {
|
|
26
|
+
try await withApp { app in
|
|
27
|
+
try await app.testing().test(.GET, "health") { res async in
|
|
28
|
+
#expect(res.status == .ok)
|
|
29
|
+
#expect(res.body.string.contains("ok"))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@Test("POST /api/items persists, trims the name (L3 rule), returns the DTO")
|
|
35
|
+
func createItem() async throws {
|
|
36
|
+
try await withApp { app in
|
|
37
|
+
try await app.testing().test(.POST, "api/items", beforeRequest: { req in
|
|
38
|
+
try req.content.encode(App.Item.DTO.Input(name: " First item "))
|
|
39
|
+
}, afterResponse: { res async throws in
|
|
40
|
+
#expect(res.status == .ok)
|
|
41
|
+
let output = try res.content.decode(App.Item.DTO.Output.self)
|
|
42
|
+
#expect(output.name == "First item") // trimming lives in the Service, not the controller
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
try await app.testing().test(.GET, "api/items") { res async throws in
|
|
46
|
+
#expect(res.status == .ok)
|
|
47
|
+
let items = try res.content.decode([App.Item.DTO.Output].self)
|
|
48
|
+
#expect(items.count == 1)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Test("Blank name → typed error contract {code, name, description}")
|
|
54
|
+
func typedErrorContract() async throws {
|
|
55
|
+
try await withApp { app in
|
|
56
|
+
try await app.testing().test(.POST, "api/items", beforeRequest: { req in
|
|
57
|
+
try req.content.encode(App.Item.DTO.Input(name: " "))
|
|
58
|
+
}, afterResponse: { res async throws in
|
|
59
|
+
#expect(res.status == .badRequest)
|
|
60
|
+
let error = try res.content.decode(
|
|
61
|
+
App.Failed.Middlewares.Handler.ErrorDescription.self
|
|
62
|
+
)
|
|
63
|
+
#expect(error.code == 400)
|
|
64
|
+
#expect(error.name == "dataNotValid") // case name = stable public identifier
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Test("Duplicate name → 409 dataAlreadyExist")
|
|
70
|
+
func duplicateItem() async throws {
|
|
71
|
+
try await withApp { app in
|
|
72
|
+
for expected in [HTTPStatus.ok, .conflict] {
|
|
73
|
+
try await app.testing().test(.POST, "api/items", beforeRequest: { req in
|
|
74
|
+
try req.content.encode(App.Item.DTO.Input(name: "Twice"))
|
|
75
|
+
}, afterResponse: { res async in
|
|
76
|
+
#expect(res.status == expected)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test("Monitoring enabled with an EMPTY token fails fast at boot (no silent /metrics 401)")
|
|
83
|
+
func monitoringEmptyTokenFailsFast() async throws {
|
|
84
|
+
let app = try await Application.make(.testing)
|
|
85
|
+
do {
|
|
86
|
+
#expect(throws: MonitoringConfigurationError.self) {
|
|
87
|
+
try configureMonitoring(app: app, enabled: true, metricsToken: "")
|
|
88
|
+
}
|
|
89
|
+
#expect(throws: MonitoringConfigurationError.self) {
|
|
90
|
+
try configureMonitoring(app: app, enabled: true, metricsToken: " ")
|
|
91
|
+
}
|
|
92
|
+
// Disabled → blank token is fine (no metrics endpoint).
|
|
93
|
+
#expect(throws: Never.self) {
|
|
94
|
+
try configureMonitoring(app: app, enabled: false, metricsToken: "")
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
try? await app.asyncShutdown()
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
try await app.asyncShutdown()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test("Duplicate name under concurrency → exactly one insert, the rest 409 (DB unique constraint)")
|
|
104
|
+
func duplicateNameRace() async throws {
|
|
105
|
+
try await withApp { app in
|
|
106
|
+
// Fire many concurrent creates of the SAME name. With a TOCTOU check-then-insert and
|
|
107
|
+
// no DB constraint, several would slip through; the unique index + typed-409 mapping
|
|
108
|
+
// guarantees exactly one success.
|
|
109
|
+
let name = "RaceMe"
|
|
110
|
+
await withTaskGroup(of: HTTPStatus.self) { group in
|
|
111
|
+
for _ in 0..<8 {
|
|
112
|
+
group.addTask {
|
|
113
|
+
var status: HTTPStatus = .internalServerError
|
|
114
|
+
do {
|
|
115
|
+
try await app.testing().test(.POST, "api/items", beforeRequest: { req in
|
|
116
|
+
try req.content.encode(App.Item.DTO.Input(name: name))
|
|
117
|
+
}, afterResponse: { res async in
|
|
118
|
+
status = res.status
|
|
119
|
+
})
|
|
120
|
+
} catch {
|
|
121
|
+
status = .internalServerError
|
|
122
|
+
}
|
|
123
|
+
return status
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
var okCount = 0
|
|
127
|
+
for await status in group {
|
|
128
|
+
if status == .ok { okCount += 1 } else { #expect(status == .conflict) }
|
|
129
|
+
}
|
|
130
|
+
#expect(okCount == 1)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@Test("/metrics is bearer-token gated (constant-time compare)")
|
|
136
|
+
func metricsTokenGate() async throws {
|
|
137
|
+
try await withApp { app in
|
|
138
|
+
try await app.testing().test(.GET, "metrics") { res async in
|
|
139
|
+
#expect(res.status == .unauthorized)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var wrong = HTTPHeaders()
|
|
143
|
+
wrong.bearerAuthorization = .init(token: "not-the-token")
|
|
144
|
+
try await app.testing().test(.GET, "metrics", headers: wrong) { res async in
|
|
145
|
+
#expect(res.status == .unauthorized)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
var headers = HTTPHeaders()
|
|
149
|
+
headers.bearerAuthorization = .init(token: "test-metrics-token")
|
|
150
|
+
try await app.testing().test(.GET, "metrics", headers: headers) { res async in
|
|
151
|
+
#expect(res.status == .ok)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} — Commands
|
|
2
|
+
|
|
3
|
+
> Only commands proven to work in THIS project, with exact flags.
|
|
4
|
+
|
|
5
|
+
## Build & test (fast loop — always first)
|
|
6
|
+
swift build
|
|
7
|
+
swift test
|
|
8
|
+
swift test --filter AppTests # one suite
|
|
9
|
+
|
|
10
|
+
## Run locally
|
|
11
|
+
cp env_dist .env # once — then fill values (never commit .env)
|
|
12
|
+
docker run -d --name {{PROJECT_NAME}}-db -p 5432:5432 \
|
|
13
|
+
-e POSTGRES_USER=vapor_username -e POSTGRES_PASSWORD=vapor_password \
|
|
14
|
+
-e POSTGRES_DB=vapor_database postgres:16-alpine
|
|
15
|
+
swift run App serve --hostname 127.0.0.1 --port 8080
|
|
16
|
+
swift run App serve --log debug # one-off verbosity bump
|
|
17
|
+
curl -s localhost:8080/health # liveness proof
|
|
18
|
+
|
|
19
|
+
## Database
|
|
20
|
+
swift run App migrate # run pending migrations manually
|
|
21
|
+
swift run App migrate --revert # revert the last batch
|
|
22
|
+
|
|
23
|
+
## Quality gates (before any ship)
|
|
24
|
+
bash scripts/validate-env-vars.sh # AppConfig.Key ↔ env_dist ↔ manifests
|
|
25
|
+
bash scripts/generate-error-codes.sh # regenerate docs/ERROR_CODES.md from Failed.swift
|
|
26
|
+
docker run --rm -v "$PWD:/src" -w /src swift:6.2-noble swift test # Linux gate
|
|
27
|
+
|
|
28
|
+
## Docker image
|
|
29
|
+
docker build -t {{BUNDLE_ID}} .
|
|
30
|
+
docker run --rm -p 8080:8080 --env-file .env {{BUNDLE_ID}}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# ARCHITECTURE — Layered Vapor API (Swift 6, server-side)
|
|
2
|
+
|
|
3
|
+
Pattern for Vapor 4 APIs deployed on Linux. The service is assembled from SPM targets
|
|
4
|
+
(compiler-enforced walls) + role folders inside the App target (convention-enforced,
|
|
5
|
+
grep-verifiable). Read `ARCHITECTURE_PRINCIPLES.md` first — this maps it onto Swift.
|
|
6
|
+
|
|
7
|
+
## 1. Layer Model — Swift instantiation of the universal L0–L5 contract
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
L5 COMPLETE FEATURES Sources/App/Configure/ entrypoint, configure, routes, AppConfig (composition root)
|
|
11
|
+
Features/<F>/Controllers/ HTTP boundary: routes, decode/encode DTOs
|
|
12
|
+
Features/<F>/DTO/ wire types (Input/Output Content structs)
|
|
13
|
+
L4 SHARED FEATURES created on demand: logic needed by ≥2 feature modules gets its own
|
|
14
|
+
SPM target (e.g. an auth or billing module). Don't pre-create.
|
|
15
|
+
L3 CORE LOGIC Features/<F>/Services/ BUSINESS RULES: validation, normalization,
|
|
16
|
+
typed errors, orchestration
|
|
17
|
+
Features/<F>/Entities/ Fluent models (domain shape + persistence mapping)
|
|
18
|
+
L2 DATA Features/<F>/Repositories/ data access ONLY — answers "what does the DB say"
|
|
19
|
+
Features/<F>/Migrations/ schema changes (append-only)
|
|
20
|
+
Features/<F>/Jobs/ queued/scheduled work (created when needed; calls Services)
|
|
21
|
+
Sources/<X>Client/ one SPM target per external API (AsyncHTTPClient)
|
|
22
|
+
L1 OPS Sources/Monitoring/ SPM target: JSON logs, HTTP timing, metrics registry
|
|
23
|
+
L0 FOUNDATION Sources/{{PROJECT_NAME}}Foundation/ SPM target: pure primitives, ZERO dependencies
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Notes on the mapping:
|
|
27
|
+
- **Targets enforce the big walls.** Foundation and Monitoring cannot import App — the
|
|
28
|
+
compiler guarantees it. External-API clients live in their own targets so they build
|
|
29
|
+
and test without booting the app.
|
|
30
|
+
- **Folders enforce the rest.** Inside the App target, layers are roles within a feature
|
|
31
|
+
module. The compiler can't help there, so the rules below must be grep-checkable.
|
|
32
|
+
- **Third-party frameworks are not layers.** Monitoring (L1) imports Vapor as
|
|
33
|
+
infrastructure; the dependency rule governs imports between PROJECT bricks only.
|
|
34
|
+
|
|
35
|
+
## 2. Dependency Direction Rules
|
|
36
|
+
|
|
37
|
+
| Layer | May use | Must NEVER use | Why |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| Controllers (L5) | Services, DTO, `App.Failed` | Fluent queries, Repositories directly | HTTP translation only — logic in services stays testable without HTTP |
|
|
40
|
+
| Services (L3) | Repositories, Entities, Foundation, `App.Failed` | `Request`, `Response`, HTTP types | Services take plain values; one service = one testable unit |
|
|
41
|
+
| Repositories (L2) | Fluent + Entities | `App.Failed` business errors, Services, validation | A repository reports DB facts; deciding what they MEAN is L3 |
|
|
42
|
+
| Migrations (L2) | Fluent schema builder | Entities' current code for data backfill | A migration must stay valid when the model changes later |
|
|
43
|
+
| Monitoring (L1) | Vapor, metrics libs | anything in App | Reusable across services; no domain knowledge |
|
|
44
|
+
| Foundation (L0) | Swift stdlib + Foundation | everything else | Primitives stay portable and instantly testable |
|
|
45
|
+
|
|
46
|
+
Grep checks (run them when in doubt — empty output = healthy):
|
|
47
|
+
```bash
|
|
48
|
+
grep -rn "query(on:" Sources/App/Features/*/Controllers/ # controllers bypassing services
|
|
49
|
+
grep -rn "import Vapor" Sources/App/Features/*/Services/ # services seeing HTTP (Request/Response live in Vapor)
|
|
50
|
+
grep -rn "App.Failed" Sources/App/Features/*/Repositories/ # repositories making decisions
|
|
51
|
+
grep -rn "URLSession" Sources/ # forbidden — GOTCHAS_LINUX_SWIFT.md
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## 3. Feature modules — the unit of growth
|
|
55
|
+
|
|
56
|
+
One folder per feature under `Sources/App/Features/`, everything colocated by role.
|
|
57
|
+
`Features/Item/` is the REFERENCE module — copy its shape:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
Features/Item/
|
|
61
|
+
├── AppItem.swift namespace + registration surfaces (the module's front door)
|
|
62
|
+
├── Controllers/ RouteCollections
|
|
63
|
+
├── DTO/ Input/Output Content structs
|
|
64
|
+
├── Entities/ Fluent models
|
|
65
|
+
├── Migrations/ append-only schema changes
|
|
66
|
+
├── Repositories/ data access
|
|
67
|
+
└── Services/ business logic
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The namespace is an enum: `extension App { enum Item { … } }` — call sites read as domain
|
|
71
|
+
language (`App.Item.Service`), and registration is type-scoped, not global.
|
|
72
|
+
|
|
73
|
+
> ⚠️ **Gotcha:** Symptom — two features need the same service and one starts importing the
|
|
74
|
+
> other's folder. Cause — sideways dependency between sibling features. Fix — promote the
|
|
75
|
+
> shared logic to its own SPM target (L4) and have both features depend on it. A feature
|
|
76
|
+
> never reaches into a sibling feature.
|
|
77
|
+
|
|
78
|
+
## 4. Auto-registration — one line per feature, ever
|
|
79
|
+
|
|
80
|
+
Three protocols in `Sources/App/Registry/` (`ControllersRegister`, `MigrationsRegister`,
|
|
81
|
+
`MiddlewaresRegister`) give every module the same wiring surface:
|
|
82
|
+
|
|
83
|
+
```swift
|
|
84
|
+
// In the module: AppItem.swift
|
|
85
|
+
extension App.Item.Controllers: ControllersRegister {
|
|
86
|
+
static func allCases() -> [any RouteCollection] { [Crud()] }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// In Configure/routes.swift — the ONLY line the rest of the app sees:
|
|
90
|
+
try App.Item.Controllers.register(app: app)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Adding a feature touches exactly two central lines: its controllers in `routes.swift`,
|
|
94
|
+
its migrations in `configure.swift`. Everything else stays inside the module folder.
|
|
95
|
+
This is what keeps agent diffs small: a new endpoint never rewrites the bootstrap.
|
|
96
|
+
|
|
97
|
+
## 5. Migration ordering — explicit, central, load-bearing
|
|
98
|
+
|
|
99
|
+
`configure.swift > migrationsInit` registers every module's migrations **in an explicit
|
|
100
|
+
sequence**. Fluent runs them in registration order; a schema referencing another table
|
|
101
|
+
(foreign key) must be registered after that table's `Create`.
|
|
102
|
+
|
|
103
|
+
> ⚠️ **Gotcha:** Symptom — fresh deploy crashes with `relation "xxx" does not exist`,
|
|
104
|
+
> while every dev machine boots fine. Cause — dev databases migrated incrementally over
|
|
105
|
+
> weeks, so ordering bugs stay invisible; only a from-scratch migration run exposes them.
|
|
106
|
+
> Fix — order registrations by foreign-key dependency, comment WHY a module is positioned
|
|
107
|
+
> where it is, and verify every migration change against a scratch database (drop +
|
|
108
|
+
> re-migrate) before shipping.
|
|
109
|
+
|
|
110
|
+
## 6. Where Does a New File Go?
|
|
111
|
+
|
|
112
|
+
| You are adding… | It lives in… |
|
|
113
|
+
|---|---|
|
|
114
|
+
| A new endpoint for an existing feature | `Features/<F>/Controllers/` (+ DTO if the shape changes) |
|
|
115
|
+
| A business rule (validation, limits, workflow) | `Features/<F>/Services/` |
|
|
116
|
+
| A query, a DB lookup | `Features/<F>/Repositories/` |
|
|
117
|
+
| A schema change | `Features/<F>/Migrations/` — NEW file, registered after its FK targets |
|
|
118
|
+
| A queued/scheduled job | `Features/<F>/Jobs/` (conform a JobsRegister-style surface) |
|
|
119
|
+
| A whole new feature | `Features/<NewF>/` copied from the Item module shape |
|
|
120
|
+
| A new error case | `Sources/App/Error/Failed.swift` + regenerate ERROR_CODES (CONVENTIONS.md) |
|
|
121
|
+
| A new environment variable | `AppConfig.Key` + `env_dist` + manifests (CONVENTIONS.md) |
|
|
122
|
+
| An external API client | new SPM target `Sources/<X>Client/` using AsyncHTTPClient |
|
|
123
|
+
| A log/metric/middleware concern | `Sources/Monitoring/` |
|
|
124
|
+
| A pure helper (string, date, parsing) | `Sources/{{PROJECT_NAME}}Foundation/` |
|
|
125
|
+
|
|
126
|
+
## 7. Why This Works Exceptionally Well With AI Agents
|
|
127
|
+
|
|
128
|
+
- **Fast ground truth without infrastructure.** `swift test` boots the real app on
|
|
129
|
+
in-memory SQLite — full middleware stack, real migrations, zero services to start.
|
|
130
|
+
- **Compiler-enforced walls where it counts.** An agent that makes Monitoring import App
|
|
131
|
+
code gets a build error, not a slow architectural rot.
|
|
132
|
+
- **Grep-visible violations everywhere else.** Every folder rule above has a one-line
|
|
133
|
+
grep; agents can self-audit before claiming done.
|
|
134
|
+
- **Predictable file placement.** The feature-module shape means an agent knows the 5–7
|
|
135
|
+
files a feature needs; diffs stay small and reviewable.
|
|
136
|
+
- **The error contract is testable.** Typed errors make failure paths assertable
|
|
137
|
+
(`#expect(error.name == "dataNotValid")`) — failure behavior survives refactors.
|
|
138
|
+
|
|
139
|
+
Build matrix an agent should run before claiming "done":
|
|
140
|
+
```bash
|
|
141
|
+
swift build && swift test # full stack on the host
|
|
142
|
+
docker run --rm -v "$PWD:/src" -w /src swift:6.2-noble swift test # Linux = the real target
|
|
143
|
+
bash scripts/validate-env-vars.sh # config drift check
|
|
144
|
+
```
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# CONVENTIONS — Server-Side Swift (Vapor 4, Swift 6 strict concurrency)
|
|
2
|
+
|
|
3
|
+
Prescriptive — follow as written. The layer rules live in `ARCHITECTURE.md`; this file is
|
|
4
|
+
about HOW each kind of code is written.
|
|
5
|
+
|
|
6
|
+
## 1. Feature module anatomy
|
|
7
|
+
|
|
8
|
+
- Namespace enum per feature: `extension App { enum Item { enum Controllers {}; enum Migrations {}; enum DTO {} } }`.
|
|
9
|
+
Sub-enums exist ONLY for roles the feature actually has — don't pre-create empty ones.
|
|
10
|
+
- **One type per file**; file name = feature + role + type: `ItemControllersCrud.swift`,
|
|
11
|
+
`ItemEntity.swift`, `ItemMigrationCreate.swift`, `ItemService.swift`, `ItemRepository.swift`.
|
|
12
|
+
- The front-door file (`AppItem.swift`) holds the namespace + ALL registration conformances —
|
|
13
|
+
reading one file tells you everything the module plugs into.
|
|
14
|
+
- Checklist for a new feature `Foo`:
|
|
15
|
+
1. Copy the `Features/Item/` shape → `Features/Foo/`.
|
|
16
|
+
2. Conform `App.Foo.Controllers: ControllersRegister`, `App.Foo.Migrations: MigrationsRegister`.
|
|
17
|
+
3. Add `try App.Foo.Controllers.register(app: app)` to `routes.swift`.
|
|
18
|
+
4. Add `App.Foo.Migrations.register(app: app)` to `configure.swift` — positioned AFTER
|
|
19
|
+
every table its schema references.
|
|
20
|
+
5. Write the feature test (boot + endpoint + typed-error case) in `Tests/AppTests/`.
|
|
21
|
+
|
|
22
|
+
## 2. Typed errors — the public failure contract
|
|
23
|
+
|
|
24
|
+
Every intentional failure is a case of an enum in `Sources/App/Error/Failed.swift`:
|
|
25
|
+
|
|
26
|
+
```swift
|
|
27
|
+
enum BadRequest: CustomError {
|
|
28
|
+
case dataNotValid
|
|
29
|
+
func convert() -> HTTPStatus { .badRequest } // ONE line — the generator parses this
|
|
30
|
+
}
|
|
31
|
+
// usage, in a Service:
|
|
32
|
+
guard !cleanName.isEmpty else { throw App.Failed.BadRequest.dataNotValid }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The middleware (`FailedMiddleware.swift`) serializes any thrown case as
|
|
36
|
+
`{"code": 400, "name": "dataNotValid", "description": "Bad Request"}` and increments the
|
|
37
|
+
error counter. Rules:
|
|
38
|
+
|
|
39
|
+
- **Feature code never throws raw `Abort`** and never builds error Responses by hand.
|
|
40
|
+
`Abort` is tolerated only in infrastructure handlers (e.g. the /metrics gate).
|
|
41
|
+
- The **case name is the public identifier** — clients match on it. Renaming a case is a
|
|
42
|
+
breaking API change; treat it like one.
|
|
43
|
+
- Throw from **Services** (business decisions) and Controllers (HTTP-shape problems like
|
|
44
|
+
undecodable JSON). Repositories return facts (`nil`, `false`), never typed errors.
|
|
45
|
+
- `docs/ERROR_CODES.md` is **GENERATED** from the enum by `scripts/generate-error-codes.sh`.
|
|
46
|
+
Never write it by hand; regenerate in the same commit that touches `Failed.swift`.
|
|
47
|
+
A hand-written error table starts lying the week after it's written — the generated one
|
|
48
|
+
cannot lie.
|
|
49
|
+
|
|
50
|
+
> ⚠️ **Gotcha:** Symptom — a client breaks on an error response with a shape nobody
|
|
51
|
+
> documented. Cause — someone threw `Abort(.badRequest, reason: "…")` in feature code,
|
|
52
|
+
> which bypasses the typed contract and produces Vapor's `{"error": true, "reason": …}`
|
|
53
|
+
> shape instead. Fix — grep `Abort(` over `Features/` in review; it should only hit
|
|
54
|
+
> infrastructure files.
|
|
55
|
+
|
|
56
|
+
## 3. Environment variables — typed, fail-fast, cross-checked
|
|
57
|
+
|
|
58
|
+
ONE pattern, no exceptions (`Sources/App/Configure/AppConfig.swift`):
|
|
59
|
+
|
|
60
|
+
- Every variable is a case of `AppConfig.Key` (CaseIterable). `Key.get` fail-fasts with
|
|
61
|
+
the variable's NAME if missing — the service dies at boot, not at the first request
|
|
62
|
+
that needed the value.
|
|
63
|
+
- `Environment.get` is called nowhere else. If you need a new value: add the Key case,
|
|
64
|
+
add a typed property on `AppConfig`, add the line to `env_dist`, add it to every deploy
|
|
65
|
+
manifest, run `bash scripts/validate-env-vars.sh`.
|
|
66
|
+
- Tests get `AppConfig.testing` through an EXPLICIT branch on `app.environment == .testing`
|
|
67
|
+
— never sniff the test runner from process arguments, env leftovers, or CI flags. The
|
|
68
|
+
environment is a parameter, not a guess.
|
|
69
|
+
- Config reaches code via `Application.storage` (`app.config` / `req.config`) — no global
|
|
70
|
+
singleton. Singletons hide the dependency and make per-test configuration impossible.
|
|
71
|
+
- `LOG_LEVEL` is the single sanctioned exception: read in `entrypoint.swift` before
|
|
72
|
+
config exists (logging must work even when configuration is broken).
|
|
73
|
+
|
|
74
|
+
> ⚠️ **Gotcha:** Symptom — deploy green, then the service dies at boot with a missing
|
|
75
|
+
> variable that "was added weeks ago". Cause — the variable was added to code and the dev
|
|
76
|
+
> .env, but never to the production manifest; nothing cross-checked them. Fix — the
|
|
77
|
+
> validate script runs in CI and blocks the merge, not the deploy.
|
|
78
|
+
|
|
79
|
+
## 4. DTOs — the wire contract
|
|
80
|
+
|
|
81
|
+
- `DTO.Input` / `DTO.Output` structs conforming to `Content`, one pair per resource shape.
|
|
82
|
+
- **Entities never serialize directly.** The entity is a persistence detail; the DTO is
|
|
83
|
+
the public API. Renaming a DB column must never be an API-breaking change.
|
|
84
|
+
- `Output.init(entity:)` is the single mapping point — `try entity.requireID()` there,
|
|
85
|
+
so an unsaved entity fails loudly instead of emitting a null id.
|
|
86
|
+
- Decode with intent: `guard let input = try? req.content.decode(...) else { throw App.Failed.BadRequest.jsonNotDecodable }`
|
|
87
|
+
— undecodable JSON is a typed 400, not a Vapor default.
|
|
88
|
+
|
|
89
|
+
## 5. Concurrency (Swift 6, strict)
|
|
90
|
+
|
|
91
|
+
- Route handlers are `@Sendable` funcs on a struct `RouteCollection`.
|
|
92
|
+
- Services and Repositories are `Sendable` structs holding `let db: any Database` —
|
|
93
|
+
created per request, no shared mutable state.
|
|
94
|
+
- Fluent models are `final class … Model, @unchecked Sendable` — the ONLY tolerated
|
|
95
|
+
`@unchecked` in the codebase, imposed by Fluent. Containment rule in
|
|
96
|
+
`GOTCHAS_LINUX_SWIFT.md`: entities never leave the request scope.
|
|
97
|
+
- **No `Task.detached`, no fire-and-forget `Task {}`** in request handlers. Work that
|
|
98
|
+
outlives the request goes through a queued Job (see GOTCHAS — request-scoped resources
|
|
99
|
+
die with the request).
|
|
100
|
+
- Process-wide one-time setup (metrics registry, log bootstrap) anchors to a lazy
|
|
101
|
+
`static let` — thread-safe by language guarantee, no locks to get wrong.
|
|
102
|
+
|
|
103
|
+
## 6. Logging
|
|
104
|
+
|
|
105
|
+
- Always through `req.logger` / `app.logger` (request-scoped loggers carry request IDs).
|
|
106
|
+
Never `print()` — invisible to the structured pipeline.
|
|
107
|
+
- **Values go in metadata, not in the message string**: the message is a stable label,
|
|
108
|
+
metadata keys are queryable (`"duration_ms"`, `"path"`, `"error_type"`).
|
|
109
|
+
- Every `catch` that swallows or converts an error logs the operation name + the error.
|
|
110
|
+
- Never log secrets, tokens, or full request bodies. Probe paths (/health, /metrics) are
|
|
111
|
+
excluded from request logs by `HTTPLoggingMiddleware` — keep it that way.
|
|
112
|
+
|
|
113
|
+
## 7. Naming quick reference
|
|
114
|
+
|
|
115
|
+
- Actions are verbs (`create`, `list`); queries are nouns; booleans read as assertions
|
|
116
|
+
(`isBlank`, `monitoringEnabled`).
|
|
117
|
+
- Routes: plural resources, kebab-case path segments (`/api/items`, `/api/team-invites`).
|
|
118
|
+
- DB: snake_case columns, plural snake_case tables (`created_at`, `items`).
|
|
119
|
+
- Migrations: `Create`, `AddXxx`, `FixXxx` — named for what they do, append-only.
|
|
120
|
+
- Names express intent, not technology (`ItemRepository`, not `ItemDBManager`).
|
|
121
|
+
- Comments explain WHY (the decision, the trap), never what the code already says.
|