@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.
Files changed (141) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +99 -0
  3. package/bin/cli.js +371 -0
  4. package/bin/cli.test.js +91 -0
  5. package/package.json +43 -0
  6. package/templates/core/CLAUDE.md +36 -0
  7. package/templates/core/claude/memory/ARCHITECTURE.md +20 -0
  8. package/templates/core/claude/memory/COMMANDS.md +13 -0
  9. package/templates/core/claude/memory/DECISIONS.md +5 -0
  10. package/templates/core/claude/memory/NEXT_STEPS.md +11 -0
  11. package/templates/core/claude/memory/PROJECT_STATE.md +24 -0
  12. package/templates/core/claude/skills/kickoff/SKILL.md +84 -0
  13. package/templates/core/claude/skills/product-owner/SKILL.md +58 -0
  14. package/templates/core/claude/skills/restore-context/SKILL.md +29 -0
  15. package/templates/core/claude/skills/save-context/SKILL.md +35 -0
  16. package/templates/core/docs-architecture/ANTI_PATTERNS.md +180 -0
  17. package/templates/core/docs-architecture/ARCHITECTURE_PRINCIPLES.md +134 -0
  18. package/templates/core/docs-architecture/DELIVERY.md +68 -0
  19. package/templates/core/docs-architecture/DOCS_PLACEMENT.md +151 -0
  20. package/templates/core/docs-architecture/MULTI_REPO_CONTRACT.md +158 -0
  21. package/templates/core/docs-architecture/SDK_CONTRACT.md +214 -0
  22. package/templates/core/docs-architecture/SECURITY_USER_URLS.md +152 -0
  23. package/templates/core/gitignore +15 -0
  24. package/templates/core/mcp.json +8 -0
  25. package/templates/packs/nuxt-web/CLAUDE.md +74 -0
  26. package/templates/packs/nuxt-web/app/app.vue +5 -0
  27. package/templates/packs/nuxt-web/app/assets/css/main.css +18 -0
  28. package/templates/packs/nuxt-web/app/assets/css/tokens.css +41 -0
  29. package/templates/packs/nuxt-web/app/designSystem/DSButton/components/DSButton.vue +70 -0
  30. package/templates/packs/nuxt-web/app/designSystem/DSButton/index.ts +4 -0
  31. package/templates/packs/nuxt-web/app/designSystem/DSButton/tests/DSButton.spec.ts +34 -0
  32. package/templates/packs/nuxt-web/app/designSystem/DSButton/types/dsButton.ts +5 -0
  33. package/templates/packs/nuxt-web/app/domain/.gitkeep +0 -0
  34. package/templates/packs/nuxt-web/app/features/.gitkeep +0 -0
  35. package/templates/packs/nuxt-web/app/pages/index.vue +36 -0
  36. package/templates/packs/nuxt-web/app/utils/.gitkeep +0 -0
  37. package/templates/packs/nuxt-web/claude/memory/COMMANDS.md +21 -0
  38. package/templates/packs/nuxt-web/docs-architecture/ARCHITECTURE.md +169 -0
  39. package/templates/packs/nuxt-web/docs-architecture/CONVENTIONS.md +140 -0
  40. package/templates/packs/nuxt-web/docs-architecture/I18N.md +102 -0
  41. package/templates/packs/nuxt-web/docs-architecture/OPS_WEB.md +176 -0
  42. package/templates/packs/nuxt-web/docs-architecture/SEO_AND_ROUTING.md +118 -0
  43. package/templates/packs/nuxt-web/gitignore +18 -0
  44. package/templates/packs/nuxt-web/nuxt.config.ts +49 -0
  45. package/templates/packs/nuxt-web/pack.json +11 -0
  46. package/templates/packs/nuxt-web/package.json +31 -0
  47. package/templates/packs/nuxt-web/playwright.config.ts +39 -0
  48. package/templates/packs/nuxt-web/server/api/health.get.ts +7 -0
  49. package/templates/packs/nuxt-web/tests/e2e/home.spec.ts +19 -0
  50. package/templates/packs/nuxt-web/tsconfig.json +4 -0
  51. package/templates/packs/nuxt-web/vitest.config.ts +23 -0
  52. package/templates/packs/swift-ios/CLAUDE.md +64 -0
  53. package/templates/packs/swift-ios/Packages/DataLayer/Package.swift +21 -0
  54. package/templates/packs/swift-ios/Packages/DataLayer/Sources/DataLayer/DataLayer.swift +11 -0
  55. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Package.swift +20 -0
  56. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Domain/SampleItem.swift +15 -0
  57. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Engine/SampleEngine.swift +14 -0
  58. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Sources/{{PROJECT_NAME}}Core/Repository/SampleItemRepository.swift +27 -0
  59. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}Core/Tests/{{PROJECT_NAME}}CoreTests/SampleEngineTests.swift +32 -0
  60. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Package.swift +17 -0
  61. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Color+DS.swift +18 -0
  62. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/Components/DSCard.swift +22 -0
  63. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DS.swift +36 -0
  64. package/templates/packs/swift-ios/Packages/{{PROJECT_NAME}}DS/Sources/{{PROJECT_NAME}}DS/DSFont.swift +26 -0
  65. package/templates/packs/swift-ios/claude/memory/COMMANDS.md +18 -0
  66. package/templates/packs/swift-ios/docs-architecture/ARCHITECTURE.md +246 -0
  67. package/templates/packs/swift-ios/docs-architecture/CLOUDKIT_GUIDE.md +224 -0
  68. package/templates/packs/swift-ios/docs-architecture/CONVENTIONS.md +246 -0
  69. package/templates/packs/swift-ios/docs-architecture/DESIGN_SYSTEM.md +272 -0
  70. package/templates/packs/swift-ios/docs-architecture/NAVIGATION.md +241 -0
  71. package/templates/packs/swift-ios/docs-architecture/TESTING.md +176 -0
  72. package/templates/packs/swift-ios/docs-architecture/WORKFLOW.md +165 -0
  73. package/templates/packs/swift-ios/github/workflows/ci.yml +48 -0
  74. package/templates/packs/swift-ios/gitignore +5 -0
  75. package/templates/packs/swift-ios/mcp.json +8 -0
  76. package/templates/packs/swift-ios/pack.json +11 -0
  77. package/templates/packs/swift-ios/project.yml +33 -0
  78. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/App.swift +32 -0
  79. package/templates/packs/swift-ios/{{PROJECT_NAME}}/App/AppNamespace.swift +4 -0
  80. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Module/.gitkeep +0 -0
  81. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Store/.gitkeep +0 -0
  82. package/templates/packs/swift-ios/{{PROJECT_NAME}}/Tools/.gitkeep +0 -0
  83. package/templates/packs/ts-sdk/CHANGELOG.md +9 -0
  84. package/templates/packs/ts-sdk/CLAUDE.md +72 -0
  85. package/templates/packs/ts-sdk/MIGRATION.md +28 -0
  86. package/templates/packs/ts-sdk/claude/memory/COMMANDS.md +21 -0
  87. package/templates/packs/ts-sdk/docs-architecture/ARCHITECTURE.md +132 -0
  88. package/templates/packs/ts-sdk/docs-architecture/CONVENTIONS_TS.md +152 -0
  89. package/templates/packs/ts-sdk/gitignore +6 -0
  90. package/templates/packs/ts-sdk/pack.json +11 -0
  91. package/templates/packs/ts-sdk/package.json +55 -0
  92. package/templates/packs/ts-sdk/scripts/verify-dist.mjs +67 -0
  93. package/templates/packs/ts-sdk/src/clients/AuthClient.ts +168 -0
  94. package/templates/packs/ts-sdk/src/core/HttpClient.ts +85 -0
  95. package/templates/packs/ts-sdk/src/core/Logger.ts +27 -0
  96. package/templates/packs/ts-sdk/src/core/SDKContext.ts +40 -0
  97. package/templates/packs/ts-sdk/src/core/withTimeout.ts +19 -0
  98. package/templates/packs/ts-sdk/src/errors/ApiError.ts +93 -0
  99. package/templates/packs/ts-sdk/src/index.ts +62 -0
  100. package/templates/packs/ts-sdk/src/types/index.ts +33 -0
  101. package/templates/packs/ts-sdk/tests/apiError.test.ts +58 -0
  102. package/templates/packs/ts-sdk/tests/httpClient.test.ts +60 -0
  103. package/templates/packs/ts-sdk/tests/singleFlight.test.ts +191 -0
  104. package/templates/packs/ts-sdk/tsconfig.json +15 -0
  105. package/templates/packs/ts-sdk/tsup.config.ts +22 -0
  106. package/templates/packs/ts-sdk/vitest.config.ts +8 -0
  107. package/templates/packs/vapor-api/CLAUDE.md +73 -0
  108. package/templates/packs/vapor-api/Dockerfile +80 -0
  109. package/templates/packs/vapor-api/Package.swift +68 -0
  110. package/templates/packs/vapor-api/Sources/App/App.swift +5 -0
  111. package/templates/packs/vapor-api/Sources/App/Configure/AppConfig.swift +108 -0
  112. package/templates/packs/vapor-api/Sources/App/Configure/configure.swift +74 -0
  113. package/templates/packs/vapor-api/Sources/App/Configure/entrypoint.swift +47 -0
  114. package/templates/packs/vapor-api/Sources/App/Configure/routes.swift +21 -0
  115. package/templates/packs/vapor-api/Sources/App/Error/Failed.swift +73 -0
  116. package/templates/packs/vapor-api/Sources/App/Error/FailedMiddleware.swift +56 -0
  117. package/templates/packs/vapor-api/Sources/App/Features/Item/AppItem.swift +38 -0
  118. package/templates/packs/vapor-api/Sources/App/Features/Item/Controllers/ItemControllersCrud.swift +41 -0
  119. package/templates/packs/vapor-api/Sources/App/Features/Item/DTO/ItemDTO.swift +22 -0
  120. package/templates/packs/vapor-api/Sources/App/Features/Item/Entities/ItemEntity.swift +30 -0
  121. package/templates/packs/vapor-api/Sources/App/Features/Item/Migrations/ItemMigrationCreate.swift +25 -0
  122. package/templates/packs/vapor-api/Sources/App/Features/Item/Repositories/ItemRepository.swift +32 -0
  123. package/templates/packs/vapor-api/Sources/App/Features/Item/Services/ItemService.swift +57 -0
  124. package/templates/packs/vapor-api/Sources/App/Registry/ControllersRegister.swift +17 -0
  125. package/templates/packs/vapor-api/Sources/App/Registry/MiddlewaresRegister.swift +15 -0
  126. package/templates/packs/vapor-api/Sources/App/Registry/MigrationsRegister.swift +18 -0
  127. package/templates/packs/vapor-api/Sources/Monitoring/Logging/JSONLogHandler.swift +59 -0
  128. package/templates/packs/vapor-api/Sources/Monitoring/Middleware/HTTPLoggingMiddleware.swift +50 -0
  129. package/templates/packs/vapor-api/Sources/Monitoring/Monitoring.swift +110 -0
  130. package/templates/packs/vapor-api/Sources/{{PROJECT_NAME}}Foundation/String+Trimmed.swift +15 -0
  131. package/templates/packs/vapor-api/Tests/AppTests/AppTests.swift +155 -0
  132. package/templates/packs/vapor-api/claude/memory/COMMANDS.md +30 -0
  133. package/templates/packs/vapor-api/docs-architecture/ARCHITECTURE.md +144 -0
  134. package/templates/packs/vapor-api/docs-architecture/CONVENTIONS.md +121 -0
  135. package/templates/packs/vapor-api/docs-architecture/GOTCHAS_LINUX_SWIFT.md +109 -0
  136. package/templates/packs/vapor-api/docs-architecture/OPS.md +102 -0
  137. package/templates/packs/vapor-api/env_dist +29 -0
  138. package/templates/packs/vapor-api/gitignore +7 -0
  139. package/templates/packs/vapor-api/pack.json +11 -0
  140. package/templates/packs/vapor-api/scripts/generate-error-codes.sh +73 -0
  141. package/templates/packs/vapor-api/scripts/validate-env-vars.sh +72 -0
@@ -0,0 +1,47 @@
1
+ import Vapor
2
+ import Logging
3
+ import Monitoring
4
+
5
+ @main
6
+ enum Entrypoint {
7
+ static func main() async throws {
8
+ var env = try Environment.detect()
9
+
10
+ // JSON structured logs in release (one object per line — Loki/Grafana-ready),
11
+ // human-readable text locally. Level comes from --log / LOG_LEVEL: NEVER hardcoded.
12
+ if env.isRelease {
13
+ let level = try Logger.Level.detect(from: &env)
14
+ LoggingSystem.bootstrap { label in
15
+ JSONLogHandler(label: label, level: level)
16
+ }
17
+ } else {
18
+ try LoggingSystem.bootstrap(from: &env)
19
+ }
20
+
21
+ let app = try await Application.make(env)
22
+
23
+ do {
24
+ try await configure(app)
25
+ } catch {
26
+ app.logger.report(error: error)
27
+ try? await app.asyncShutdown()
28
+ throw error
29
+ }
30
+ try await app.execute()
31
+ try await app.asyncShutdown()
32
+ }
33
+ }
34
+
35
+ private extension Logger.Level {
36
+ /// Precedence: --log CLI flag > LOG_LEVEL env var > environment default.
37
+ static func detect(from environment: inout Environment) throws -> Logger.Level {
38
+ struct LogSignature: CommandSignature {
39
+ @Option(name: "log", help: "Change log level")
40
+ var level: Logger.Level?
41
+ init() { }
42
+ }
43
+ return try LogSignature(from: &environment.commandInput).level
44
+ ?? Environment.process.LOG_LEVEL
45
+ ?? (environment == .production ? .notice : .info)
46
+ }
47
+ }
@@ -0,0 +1,21 @@
1
+ import Fluent
2
+ import Vapor
3
+ import Monitoring
4
+
5
+ func routes(_ app: Application) throws {
6
+ // Feature modules — exactly ONE register line per module (auto-registration:
7
+ // the module's Controllers enum conforms to ControllersRegister).
8
+ try App.Item.Controllers.register(app: app)
9
+
10
+ // Liveness probe for load balancers / uptime monitors.
11
+ // Unauthenticated by design; excluded from request logs (HTTPLoggingMiddleware).
12
+ app.get("health") { _ in
13
+ ["status": "ok"]
14
+ }
15
+
16
+ // Prometheus scrape endpoint — bearer-token gated with a constant-time compare.
17
+ // Handler lives in the Monitoring target (L1); see docs-architecture/OPS.md.
18
+ app.get("metrics") { req in
19
+ try handleMetricsRequest(req)
20
+ }
21
+ }
@@ -0,0 +1,73 @@
1
+ import Vapor
2
+
3
+ extension App {
4
+ /// Typed error contract. Every error this API intentionally returns is a case of one
5
+ /// of the enums below; the middleware serializes it as `{code, name, description}`.
6
+ ///
7
+ /// Rules (see docs-architecture/CONVENTIONS.md):
8
+ /// - Feature code throws `App.Failed.*` — never raw `Abort`, never string errors.
9
+ /// - One enum per HTTP status; the case name IS the public error identifier.
10
+ /// - Keep `convert()` on ONE line — `scripts/generate-error-codes.sh` parses this file
11
+ /// to generate `docs/ERROR_CODES.md`. That doc is GENERATED, never hand-written.
12
+ enum Failed {
13
+ enum Middlewares {}
14
+ }
15
+ }
16
+
17
+ extension App.Failed {
18
+
19
+ protocol CustomError: Error {
20
+ func convert() -> HTTPStatus
21
+ }
22
+
23
+ enum BadRequest: CustomError {
24
+ case jsonNotDecodable
25
+ case queryNotFound
26
+ case pathNotFound
27
+ case idNotFound
28
+ case dataNotValid
29
+
30
+ func convert() -> HTTPStatus { .badRequest }
31
+ }
32
+
33
+ enum Unauthorized: CustomError {
34
+ case unauthorized
35
+
36
+ func convert() -> HTTPStatus { .unauthorized }
37
+ }
38
+
39
+ enum Forbidden: CustomError {
40
+ case accessDenied
41
+
42
+ func convert() -> HTTPStatus { .forbidden }
43
+ }
44
+
45
+ enum NotFound: CustomError {
46
+ case dataNotFound
47
+
48
+ func convert() -> HTTPStatus { .notFound }
49
+ }
50
+
51
+ enum Conflict: CustomError {
52
+ case dataAlreadyExist
53
+
54
+ func convert() -> HTTPStatus { .conflict }
55
+ }
56
+
57
+ enum InternalServer: CustomError {
58
+ case jsonNotEncodable
59
+ case databaseError
60
+ case unknown
61
+
62
+ func convert() -> HTTPStatus { .internalServerError }
63
+ }
64
+ }
65
+
66
+ // MARK: - Middlewares
67
+ extension App.Failed.Middlewares: MiddlewaresRegister {
68
+ static func allCases() -> [any Middleware] {
69
+ [
70
+ Handler()
71
+ ]
72
+ }
73
+ }
@@ -0,0 +1,56 @@
1
+ import Vapor
2
+ import Monitoring
3
+
4
+ extension App.Failed.Middlewares {
5
+ /// Converts thrown `App.Failed.CustomError` values into the public error contract:
6
+ /// HTTP status from `convert()`, JSON body `{code, name, description}`.
7
+ ///
8
+ /// `name` is the enum case (stable, machine-matchable by clients); `description` is
9
+ /// the human-readable status reason. Untyped errors are logged with full context,
10
+ /// counted, and rethrown so Vapor's ErrorMiddleware formats them — a typed API must
11
+ /// make untyped errors LOUD, not pretty.
12
+ struct Handler: AsyncMiddleware {
13
+ struct ErrorDescription: Codable {
14
+ let code: UInt
15
+ let name: String
16
+ let description: String
17
+ }
18
+
19
+ func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
20
+ do {
21
+ return try await next.respond(to: request)
22
+ } catch let error as any App.Failed.CustomError {
23
+ let status = error.convert()
24
+
25
+ recordHTTPError(status: status.code, errorName: "\(error)")
26
+
27
+ let payload = ErrorDescription(
28
+ code: status.code,
29
+ name: "\(error)",
30
+ description: status.reasonPhrase
31
+ )
32
+
33
+ do {
34
+ let body = try Response.Body(data: JSONEncoder().encode(payload))
35
+ var headers = HTTPHeaders()
36
+ headers.add(name: .contentType, value: "application/json; charset=utf-8")
37
+ return Response(status: status, headers: headers, body: body)
38
+ } catch {
39
+ return Response(status: .internalServerError,
40
+ body: .init(string: "Error encoding error response"))
41
+ }
42
+ } catch {
43
+ request.logger.error("Unhandled error", metadata: [
44
+ "error_type": "\(type(of: error))",
45
+ "error_description": "\(String(reflecting: error))",
46
+ "path": "\(request.url.path)",
47
+ "method": "\(request.method.rawValue)",
48
+ ])
49
+
50
+ recordHTTPError(status: 500, errorName: "untyped")
51
+
52
+ throw error
53
+ }
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,38 @@
1
+ import Fluent
2
+ import Vapor
3
+
4
+ extension App {
5
+ /// REFERENCE FEATURE MODULE — copy this shape for every new feature.
6
+ ///
7
+ /// A feature module is one folder under `Features/` holding everything the feature
8
+ /// needs, split by role: Controllers (L5), DTO (L5), Services (L3), Entities (L3),
9
+ /// Repositories (L2), Migrations (L2), Jobs (L2, when needed).
10
+ ///
11
+ /// This file is the module's front door: the namespace + its registration surfaces.
12
+ /// Wiring a feature into the app takes exactly two lines elsewhere:
13
+ /// - `try App.Item.Controllers.register(app: app)` in Configure/routes.swift
14
+ /// - `App.Item.Migrations.register(app: app)` in Configure/configure.swift (ORDER MATTERS)
15
+ enum Item {
16
+ enum Controllers {}
17
+ enum Migrations {}
18
+ enum DTO {}
19
+ }
20
+ }
21
+
22
+ // MARK: - Controllers
23
+ extension App.Item.Controllers: ControllersRegister {
24
+ static func allCases() -> [any RouteCollection] {
25
+ [
26
+ Crud()
27
+ ]
28
+ }
29
+ }
30
+
31
+ // MARK: - Migrations
32
+ extension App.Item.Migrations: MigrationsRegister {
33
+ static func allCases() -> [any Migration] {
34
+ [
35
+ Create()
36
+ ]
37
+ }
38
+ }
@@ -0,0 +1,41 @@
1
+ import Fluent
2
+ import Vapor
3
+
4
+ extension App.Item.Controllers {
5
+ /// L5 — HTTP boundary only: decode DTO.Input, call the Service, encode DTO.Output.
6
+ /// No Fluent queries here, no business rules — if a guard encodes a domain decision,
7
+ /// it belongs in the Service.
8
+ struct Crud: RouteCollection {
9
+ func boot(routes: any RoutesBuilder) throws {
10
+ let items = routes.grouped("api", "items")
11
+ items.get(use: index)
12
+ items.post(use: create)
13
+ items.get(":itemID", use: get)
14
+ }
15
+
16
+ @Sendable
17
+ func index(req: Request) async throws -> [App.Item.DTO.Output] {
18
+ try await App.Item.Service(db: req.db)
19
+ .list()
20
+ .map { try App.Item.DTO.Output(entity: $0) }
21
+ }
22
+
23
+ @Sendable
24
+ func get(req: Request) async throws -> App.Item.DTO.Output {
25
+ guard let id = req.parameters.get("itemID", as: UUID.self) else {
26
+ throw App.Failed.BadRequest.idNotFound
27
+ }
28
+ let entity = try await App.Item.Service(db: req.db).get(id)
29
+ return try App.Item.DTO.Output(entity: entity)
30
+ }
31
+
32
+ @Sendable
33
+ func create(req: Request) async throws -> App.Item.DTO.Output {
34
+ guard let input = try? req.content.decode(App.Item.DTO.Input.self) else {
35
+ throw App.Failed.BadRequest.jsonNotDecodable
36
+ }
37
+ let entity = try await App.Item.Service(db: req.db).create(name: input.name)
38
+ return try App.Item.DTO.Output(entity: entity)
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,22 @@
1
+ import Vapor
2
+
3
+ extension App.Item.DTO {
4
+ /// Wire types. Entities NEVER serialize directly — the DTO is the public contract,
5
+ /// the entity is a private persistence detail (renaming a column must not be an
6
+ /// API-breaking change).
7
+ struct Input: Content {
8
+ let name: String
9
+ }
10
+
11
+ struct Output: Content {
12
+ let id: UUID
13
+ let name: String
14
+ let createdAt: Date?
15
+
16
+ init(entity: App.Item.Entity) throws {
17
+ self.id = try entity.requireID()
18
+ self.name = entity.name
19
+ self.createdAt = entity.createdAt
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,30 @@
1
+ import Fluent
2
+ import Vapor
3
+
4
+ extension App.Item {
5
+ typealias Entities = [Entity]
6
+
7
+ /// Fluent model. `@unchecked Sendable` is the Fluent-imposed exception to the
8
+ /// no-unchecked rule — property wrappers make models mutable reference types.
9
+ /// CONTAINMENT RULE: an Entity never crosses a concurrency boundary and never leaves
10
+ /// the request scope — map to DTO.Output at the controller edge
11
+ /// (docs-architecture/GOTCHAS_LINUX_SWIFT.md).
12
+ final class Entity: Model, @unchecked Sendable {
13
+ static let schema = "items"
14
+
15
+ @ID(key: .id)
16
+ var id: UUID?
17
+
18
+ @Field(key: "name")
19
+ var name: String
20
+
21
+ @Timestamp(key: "created_at", on: .create)
22
+ var createdAt: Date?
23
+
24
+ init() { }
25
+
26
+ init(name: String) {
27
+ self.name = name
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,25 @@
1
+ import Fluent
2
+
3
+ extension App.Item.Migrations {
4
+ /// Schema changes are append-only: never edit a shipped migration — add a new one
5
+ /// (`AddXxx`, `FixXxx`) and list it after this one in AppItem.swift.
6
+ struct Create: AsyncMigration {
7
+ typealias Entity = App.Item.Entity
8
+
9
+ func prepare(on database: any Database) async throws {
10
+ try await database.schema(Entity.schema)
11
+ .id()
12
+ .field("name", .string, .required)
13
+ .field("created_at", .datetime)
14
+ // Name uniqueness is enforced by the DATABASE, not by a check-then-insert in
15
+ // the service (that read+write is a TOCTOU race under concurrency). The Service
16
+ // still pre-checks for a clean 409, but this constraint is the source of truth.
17
+ .unique(on: "name")
18
+ .create()
19
+ }
20
+
21
+ func revert(on database: any Database) async throws {
22
+ try await database.schema(Entity.schema).delete()
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,32 @@
1
+ import Fluent
2
+ import Foundation
3
+
4
+ extension App.Item {
5
+ /// L2 — DATA ACCESS ONLY. A repository answers "what does the database say" and
6
+ /// nothing else: no validation, no normalization, no typed business errors, no
7
+ /// orchestration. All of that lives in Service (L3). If a method here starts making
8
+ /// decisions, it is in the wrong layer.
9
+ struct Repository: Sendable {
10
+ let db: any Database
11
+
12
+ func all() async throws -> Entities {
13
+ try await Entity.query(on: db)
14
+ .sort(\.$name)
15
+ .all()
16
+ }
17
+
18
+ func find(_ id: UUID) async throws -> Entity? {
19
+ try await Entity.find(id, on: db)
20
+ }
21
+
22
+ func exists(name: String) async throws -> Bool {
23
+ try await Entity.query(on: db)
24
+ .filter(\.$name == name)
25
+ .count() > 0
26
+ }
27
+
28
+ func save(_ entity: Entity) async throws {
29
+ try await entity.save(on: db)
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,57 @@
1
+ import Fluent
2
+ import FluentKit
3
+ import Foundation
4
+ import {{PROJECT_NAME}}Foundation
5
+
6
+ extension App.Item {
7
+ /// L3 — BUSINESS LOGIC for the Item feature. The only layer that makes decisions:
8
+ /// validation, normalization, typed errors, multi-step orchestration.
9
+ /// Controllers translate HTTP ↔ DTO and call the service; the repository only
10
+ /// touches the database. Services never see `Request` — they take plain values,
11
+ /// which keeps them testable without HTTP.
12
+ struct Service: Sendable {
13
+ let repository: Repository
14
+
15
+ init(db: any Database) {
16
+ self.repository = Repository(db: db)
17
+ }
18
+
19
+ func list() async throws -> Entities {
20
+ try await repository.all()
21
+ }
22
+
23
+ func get(_ id: UUID) async throws -> Entity {
24
+ guard let entity = try await repository.find(id) else {
25
+ throw App.Failed.NotFound.dataNotFound
26
+ }
27
+ return entity
28
+ }
29
+
30
+ /// Business rules: names are stored trimmed, must not be blank, must be unique.
31
+ ///
32
+ /// Uniqueness is enforced by the DB's `.unique(on: "name")` constraint, NOT by the
33
+ /// pre-check below: `exists` + `save` is a TOCTOU race — two concurrent creates can both
34
+ /// pass the check and both insert. The pre-check stays only to return a clean 409 on the
35
+ /// common (uncontended) path; the constraint-failure catch is what makes it correct under
36
+ /// concurrency, mapping the DB violation to the SAME typed 409.
37
+ func create(name: String) async throws -> Entity {
38
+ let cleanName = name.trimmed
39
+
40
+ guard !cleanName.isEmpty else {
41
+ throw App.Failed.BadRequest.dataNotValid
42
+ }
43
+ guard try await repository.exists(name: cleanName) == false else {
44
+ throw App.Failed.Conflict.dataAlreadyExist
45
+ }
46
+
47
+ let entity = Entity(name: cleanName)
48
+ do {
49
+ try await repository.save(entity)
50
+ } catch let error as any DatabaseError where error.isConstraintFailure {
51
+ // A concurrent insert won the race after our pre-check — same outcome, typed 409.
52
+ throw App.Failed.Conflict.dataAlreadyExist
53
+ }
54
+ return entity
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,17 @@
1
+ import Vapor
2
+
3
+ /// Auto-registration surface for a feature module's controllers.
4
+ ///
5
+ /// A feature conforms its `Controllers` namespace enum and lists its RouteCollections in
6
+ /// `allCases()`; `routes.swift` then needs exactly ONE line per feature:
7
+ /// `try App.<Feature>.Controllers.register(app: app)`.
8
+ protocol ControllersRegister {
9
+ static func allCases() -> [any RouteCollection]
10
+ static func register(app: Application) throws
11
+ }
12
+
13
+ extension ControllersRegister {
14
+ static func register(app: Application) throws {
15
+ try allCases().forEach { try app.register(collection: $0) }
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ import Vapor
2
+
3
+ /// Auto-registration surface for a feature module's global middlewares.
4
+ /// Used sparingly — most features need none. The typed-error handler (App.Failed) is the
5
+ /// canonical conformer.
6
+ protocol MiddlewaresRegister {
7
+ static func allCases() -> [any Middleware]
8
+ static func register(app: Application)
9
+ }
10
+
11
+ extension MiddlewaresRegister {
12
+ static func register(app: Application) {
13
+ allCases().forEach { app.middleware.use($0) }
14
+ }
15
+ }
@@ -0,0 +1,18 @@
1
+ import Fluent
2
+ import Vapor
3
+
4
+ /// Auto-registration surface for a feature module's migrations.
5
+ ///
6
+ /// `allCases()` order = execution order WITHIN the feature. Order ACROSS features is
7
+ /// decided by the explicit `register` call sequence in `configure.swift` — that ordering
8
+ /// is load-bearing (foreign keys), see docs-architecture/GOTCHAS_LINUX_SWIFT.md.
9
+ protocol MigrationsRegister {
10
+ static func allCases() -> [any Migration]
11
+ static func register(app: Application)
12
+ }
13
+
14
+ extension MigrationsRegister {
15
+ static func register(app: Application) {
16
+ allCases().forEach { app.migrations.add($0) }
17
+ }
18
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+ import Logging
3
+
4
+ /// A LogHandler that writes one JSON object per line to stdout — directly parseable by
5
+ /// Loki/Grafana/CloudWatch. Bootstrapped in `entrypoint.swift` for release builds only;
6
+ /// development keeps Vapor's human-readable text handler.
7
+ public struct JSONLogHandler: LogHandler {
8
+ public var logLevel: Logger.Level
9
+ public var metadata: Logger.Metadata = [:]
10
+
11
+ private let label: String
12
+
13
+ public subscript(metadataKey key: String) -> Logger.Metadata.Value? {
14
+ get { metadata[key] }
15
+ set { metadata[key] = newValue }
16
+ }
17
+
18
+ public init(label: String, level: Logger.Level = .info) {
19
+ self.label = label
20
+ self.logLevel = level
21
+ }
22
+
23
+ public func log(
24
+ level: Logger.Level,
25
+ message: Logger.Message,
26
+ metadata: Logger.Metadata?,
27
+ source: String,
28
+ file: String,
29
+ function: String,
30
+ line: UInt
31
+ ) {
32
+ var merged = self.metadata
33
+ if let extra = metadata {
34
+ merged.merge(extra) { _, new in new }
35
+ }
36
+
37
+ let timestamp = Date().formatted(
38
+ .iso8601.year().month().day().time(includingFractionalSeconds: true)
39
+ )
40
+
41
+ var dict: [String: String] = [
42
+ "timestamp": timestamp,
43
+ "level": level.rawValue,
44
+ "message": "\(message)",
45
+ "source": label,
46
+ ]
47
+
48
+ // Metadata becomes flat top-level keys — queryable without JSON-path gymnastics.
49
+ for (key, value) in merged {
50
+ dict[key] = "\(value)"
51
+ }
52
+
53
+ if let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys]),
54
+ let jsonString = String(data: jsonData, encoding: .utf8) {
55
+ // Single fputs call per line: lines never interleave across threads.
56
+ fputs(jsonString + "\n", stdout)
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,50 @@
1
+ import Vapor
2
+
3
+ /// Logs every HTTP request with its duration, skipping configured paths.
4
+ /// Replaces Vapor's default RouteLoggingMiddleware to:
5
+ /// - keep noisy probe paths (/metrics, /health) out of the logs
6
+ /// - include response duration in the log metadata
7
+ public struct HTTPLoggingMiddleware: AsyncMiddleware {
8
+ private let excludedPaths: Set<String>
9
+
10
+ public init(excludedPaths: [String] = ["/metrics", "/health"]) {
11
+ self.excludedPaths = Set(excludedPaths)
12
+ }
13
+
14
+ public func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
15
+ let path = request.url.path
16
+
17
+ guard !excludedPaths.contains(path) else {
18
+ return try await next.respond(to: request)
19
+ }
20
+
21
+ // DispatchTime, not Date: monotonic — wall-clock jumps (NTP) can't produce
22
+ // negative or absurd durations.
23
+ let start = DispatchTime.now()
24
+
25
+ do {
26
+ let response = try await next.respond(to: request)
27
+ let durationMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
28
+
29
+ request.logger.info("\(request.method) \(path) \(response.status.code)", metadata: [
30
+ "method": "\(request.method)",
31
+ "path": "\(path)",
32
+ "status": "\(response.status.code)",
33
+ "duration_ms": "\(String(format: "%.1f", durationMs))",
34
+ ])
35
+
36
+ return response
37
+ } catch {
38
+ let durationMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000
39
+
40
+ request.logger.error("\(request.method) \(path) ERROR", metadata: [
41
+ "method": "\(request.method)",
42
+ "path": "\(path)",
43
+ "duration_ms": "\(String(format: "%.1f", durationMs))",
44
+ "error": "\(error)",
45
+ ])
46
+
47
+ throw error
48
+ }
49
+ }
50
+ }