@starci/hfs 3.0.0 → 4.0.1

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 (199) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -21
  3. package/bin/hfs.mjs +59 -37
  4. package/lint/run.mjs +70 -39
  5. package/package.json +2 -2
  6. package/runtime/engine/admission.mjs +3 -3
  7. package/runtime/engine/ledger-db.mjs +2 -2
  8. package/runtime/engine/machine-db.mjs +90 -9
  9. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
  10. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
  11. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  12. package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
  13. package/runtime/knowledge/hfs/slots.yaml +193 -128
  14. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  15. package/runtime/modules/kernel/failure-codes.yaml +23 -32
  16. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  17. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  18. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  19. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  20. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  21. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  22. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  23. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  24. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  25. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  26. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  27. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  28. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  29. package/runtime/scripts/lib/hfs-check.mjs +156 -141
  30. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  31. package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
  32. package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
  33. package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
  34. package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
  35. package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
  36. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  37. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  38. package/runtime/scripts/lib/language.mjs +11 -1
  39. package/runtime/scripts/lib/safe-remove.mjs +95 -10
  40. package/scaffold/app.mjs +205 -0
  41. package/scaffold/service.mjs +26 -16
  42. package/sync/cli.mjs +1 -1
  43. package/sync/hygiene.mjs +11 -8
  44. package/sync/index.mjs +109 -111
  45. package/sync/managed.mjs +9 -8
  46. package/sync/sonar-key.mjs +20 -22
  47. package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
  48. package/templates/app/gitignore +6 -0
  49. package/templates/app/hooks/husky/pre-commit +25 -0
  50. package/templates/app/hooks/husky/pre-push +7 -0
  51. package/templates/app/package-scripts/package.json +22 -0
  52. package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
  53. package/templates/app/skeleton/.editorconfig +15 -0
  54. package/templates/app/skeleton/.gitattributes +2 -0
  55. package/templates/app/skeleton/.nvmrc +1 -0
  56. package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
  57. package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
  58. package/templates/app/skeleton/README.md +36 -0
  59. package/templates/app/skeleton/scripts/codegen.mjs +4 -0
  60. package/templates/{fe → app}/tool-config/prettierignore +4 -1
  61. package/templates/be/skeleton/.sops.yaml +2 -0
  62. package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
  63. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
  64. package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
  65. package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
  66. package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
  67. package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
  68. package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
  69. package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
  70. package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
  71. package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
  72. package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
  73. package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
  74. package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
  75. package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
  76. package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
  77. package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
  78. package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
  79. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
  80. package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
  81. package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
  82. package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
  83. package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
  84. package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
  85. package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
  86. package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
  87. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
  88. package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
  89. package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
  90. package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
  91. package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
  92. package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
  93. package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
  94. package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
  95. package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
  96. package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
  97. package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
  98. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
  99. package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
  100. package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
  101. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
  102. package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
  103. package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
  104. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
  105. package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
  106. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
  107. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
  108. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
  109. package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
  110. package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
  111. package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
  112. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
  113. package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
  114. package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
  115. package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
  116. package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
  117. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
  118. package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
  119. package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
  120. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
  121. package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
  122. package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
  123. package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
  124. package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
  125. package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
  126. package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
  127. package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
  128. package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
  129. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
  130. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
  131. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
  132. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
  133. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
  134. package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
  135. package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
  136. package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
  137. package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
  138. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
  139. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
  140. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
  141. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
  142. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
  143. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
  144. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
  145. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
  146. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
  147. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
  148. package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
  149. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
  150. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
  151. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
  152. package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
  153. package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
  154. package/sync/skeleton.mjs +0 -76
  155. package/templates/be/gitignore +0 -2
  156. package/templates/be/hooks/husky/pre-commit +0 -13
  157. package/templates/be/hooks/husky/pre-push +0 -6
  158. package/templates/be/package-scripts/package.json +0 -19
  159. package/templates/be/skeleton/scripts/.gitkeep +0 -0
  160. package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
  161. package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
  162. package/templates/be/tool-config/prettierignore +0 -8
  163. package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
  164. package/templates/fe/gitignore +0 -3
  165. package/templates/fe/hooks/husky/pre-commit +0 -16
  166. package/templates/fe/hooks/husky/pre-push +0 -5
  167. package/templates/fe/package-scripts/package.json +0 -13
  168. package/templates/fe/parts/api-client.ts +0 -44
  169. package/templates/fe/parts/api-outcome.ts +0 -7
  170. package/templates/fe/quality-config/sonar-project.properties +0 -8
  171. package/templates/fe/skeleton/scripts/.gitkeep +0 -0
  172. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
  173. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
  174. package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
  175. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
  176. package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
  177. package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
  178. package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
  179. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
  180. package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
  181. package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
  182. package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
  183. package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
  184. package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
  185. package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
  186. package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
  187. package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
  188. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
  189. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
  190. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
  191. package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
  192. package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
  193. package/templates/fe/tool-config/prettierrc +0 -1
  194. /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
  195. /package/templates/{be → app}/starciwork.gitignore +0 -0
  196. /package/templates/{be → app}/tool-config/prettierrc +0 -0
  197. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
  198. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
  199. /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
@@ -0,0 +1,24 @@
1
+ import { Module } from "@nestjs/common"
2
+ import type { DynamicModule } from "@nestjs/common"
3
+ import { BundleMessageCatalog } from "./bundle-message-catalog.service"
4
+ import { MESSAGE_CATALOG, REQUEST_LOCALE } from "./i18n.decorators"
5
+ import { ConfigurableModuleClass, OPTIONS_TYPE } from "./i18n.module-definition"
6
+ import { AcceptLanguageRequestLocale } from "./request-locale.service"
7
+
8
+ @Module({})
9
+ /** Provides the MessageCatalog and RequestLocale ports. */
10
+ export class I18nModule extends ConfigurableModuleClass {
11
+ /** Registers the capability once per app with the bundles of every owner it composes. */
12
+ static register(options: typeof OPTIONS_TYPE): DynamicModule {
13
+ const base = super.register(options)
14
+ return {
15
+ ...base,
16
+ providers: [
17
+ ...(base.providers ?? []),
18
+ { provide: MESSAGE_CATALOG, useClass: BundleMessageCatalog },
19
+ { provide: REQUEST_LOCALE, useClass: AcceptLanguageRequestLocale },
20
+ ],
21
+ exports: [MESSAGE_CATALOG, REQUEST_LOCALE],
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,7 @@
1
+ import type { MessageBundle } from "./i18n.contracts"
2
+
3
+ /** Options of the i18n capability: every owner catalog the app composes. */
4
+ export interface I18nOptions {
5
+ /** The bundles to merge; each key belongs to exactly one bundle. */
6
+ readonly bundles: ReadonlyArray<MessageBundle>
7
+ }
@@ -0,0 +1,13 @@
1
+ import type { AcceptLanguage, Locale, MessageParams } from "./i18n.contracts"
2
+
3
+ /** The message catalog port: a keyed lookup with named interpolation over the bundles the app composes. */
4
+ export interface MessageCatalog {
5
+ /** The text of `key` in `locale` with `{{name}}` placeholders filled from `params`; an unknown key answers the key itself. */
6
+ get(key: string, params: MessageParams, locale: Locale): string
7
+ }
8
+
9
+ /** Picks the locale of one request. */
10
+ export interface RequestLocale {
11
+ /** The locale of an `Accept-Language` header value: Vietnamese or English, Vietnamese when it names neither. */
12
+ of(acceptLanguage: AcceptLanguage): Locale
13
+ }
@@ -0,0 +1,4 @@
1
+ export type { Locale, MessageBundle } from "./i18n.contracts"
2
+ export { InjectMessageCatalog, InjectRequestLocale, MESSAGE_CATALOG } from "./i18n.decorators"
3
+ export { I18nModule } from "./i18n.module"
4
+ export type { MessageCatalog, RequestLocale } from "./i18n.port"
@@ -0,0 +1,45 @@
1
+ import { Test } from "@nestjs/testing"
2
+ import { AcceptLanguageRequestLocale } from "./request-locale.service"
3
+
4
+ const build = async () => {
5
+ const moduleRef = await Test.createTestingModule({ providers: [AcceptLanguageRequestLocale] }).compile()
6
+ return moduleRef.get(AcceptLanguageRequestLocale)
7
+ }
8
+
9
+ describe("AcceptLanguageRequestLocale", () => {
10
+ describe("of", () => {
11
+ it.each([
12
+ ["en-US,en;q=0.9,vi;q=0.8", "en"],
13
+ ["vi-VN,vi;q=0.9", "vi"],
14
+ ["EN", "en"],
15
+ ])("picks the first supported language of %s", async (header, expected) => {
16
+ const locale = await build()
17
+
18
+ expect(locale.of(header)).toBe(expected)
19
+ })
20
+
21
+ it("skips unsupported languages until a supported one", async () => {
22
+ const locale = await build()
23
+
24
+ expect(locale.of("fr-FR, de;q=0.8, en;q=0.5")).toBe("en")
25
+ })
26
+
27
+ it("joins a header sent as several lines", async () => {
28
+ const locale = await build()
29
+
30
+ expect(locale.of(["fr", "en-GB"])).toBe("en")
31
+ })
32
+
33
+ it("defaults to Vietnamese when nothing supported is listed", async () => {
34
+ const locale = await build()
35
+
36
+ expect(locale.of("fr, de")).toBe("vi")
37
+ })
38
+
39
+ it("defaults to Vietnamese when the header is absent", async () => {
40
+ const locale = await build()
41
+
42
+ expect(locale.of(undefined)).toBe("vi")
43
+ })
44
+ })
45
+ })
@@ -0,0 +1,19 @@
1
+ import { Injectable } from "@nestjs/common"
2
+ import type { AcceptLanguage, Locale } from "./i18n.contracts"
3
+ import type { RequestLocale } from "./i18n.port"
4
+
5
+ const DEFAULT_LOCALE: Locale = "vi"
6
+
7
+ @Injectable()
8
+ /** Picks Vietnamese or English from Accept-Language, Vietnamese by default (this example stores no user preference). */
9
+ export class AcceptLanguageRequestLocale implements RequestLocale {
10
+ /** The first listed language that is Vietnamese or English, Vietnamese when none is. */
11
+ of(acceptLanguage: AcceptLanguage): Locale {
12
+ const header = typeof acceptLanguage === "string" ? acceptLanguage : (acceptLanguage ?? []).join(",")
13
+ for (const part of header.split(",")) {
14
+ const language = part.trim().slice(0, 2).toLowerCase()
15
+ if (language === "vi" || language === "en") return language
16
+ }
17
+ return DEFAULT_LOCALE
18
+ }
19
+ }
@@ -1,5 +1,5 @@
1
1
  export { createJsonLogger } from "./json-logger.service"
2
- export { InjectLogger } from "./logging.decorators"
2
+ export { InjectLogger, LOGGER } from "./logging.decorators"
3
3
  export { LoggingLogEvent } from "./logging.log-events"
4
4
  export { LoggingModule } from "./logging.module"
5
5
  export type { Logger } from "./logging.port"
@@ -1,99 +1,116 @@
1
- import { Test } from "@nestjs/testing"
2
- import { FakeClock } from "@starci/jest-preset"
1
+ import type { Writable } from "node:stream"
2
+ import { FakeClock, mock } from "@starci/jest-preset"
3
3
  import { CLOCK } from "@modules/platform/clock"
4
- import { JsonLoggerService, createJsonLogger } from "./json-logger.service"
5
-
6
- const TIME = "2026-01-01T00:00:00.000Z"
4
+ import { Test } from "@nestjs/testing"
5
+ import { createJsonLogger, JsonLoggerService } from "./json-logger.service"
6
+ import { LOG_ERR, LOG_OUT } from "./logging.decorators"
7
+ import { LoggingLogEvent } from "./logging.log-events"
7
8
 
8
9
  const build = async () => {
9
- const clock = new FakeClock(TIME)
10
+ const clock = new FakeClock("2026-05-06T07:08:09.000Z")
11
+ const written: Array<{ stream: "out" | "err"; line: string }> = []
12
+ const out = mock<Writable>({
13
+ write: (chunk: string) => {
14
+ written.push({ stream: "out", line: chunk })
15
+ return true
16
+ },
17
+ })
18
+ const err = mock<Writable>({
19
+ write: (chunk: string) => {
20
+ written.push({ stream: "err", line: chunk })
21
+ return true
22
+ },
23
+ })
10
24
  const moduleRef = await Test.createTestingModule({
11
- providers: [JsonLoggerService, { provide: CLOCK, useValue: clock }],
25
+ providers: [
26
+ JsonLoggerService,
27
+ { provide: CLOCK, useValue: clock },
28
+ { provide: LOG_OUT, useValue: out },
29
+ { provide: LOG_ERR, useValue: err },
30
+ ],
12
31
  }).compile()
13
- return { service: moduleRef.get(JsonLoggerService), clock }
32
+ return { logger: moduleRef.get(JsonLoggerService), clock, written }
14
33
  }
15
34
 
16
- /** One JSON line, the way the logger writes it. */
17
- const line = (fields: Record<string, unknown>): string => `${JSON.stringify(fields)}\n`
18
-
19
35
  describe("JsonLoggerService", () => {
20
- const stdout: Array<string> = []
21
- const stderr: Array<string> = []
22
-
23
- beforeEach(() => {
24
- stdout.length = 0
25
- stderr.length = 0
26
- jest.spyOn(process.stdout, "write").mockImplementation((chunk) => {
27
- stdout.push(String(chunk))
28
- return true
29
- })
30
- jest.spyOn(process.stderr, "write").mockImplementation((chunk) => {
31
- stderr.push(String(chunk))
32
- return true
33
- })
34
- })
36
+ it("writes an info line to the out stream stamped by the clock", async () => {
37
+ const { logger, written } = await build()
35
38
 
36
- afterEach(() => {
37
- jest.restoreAllMocks()
38
- })
39
+ logger.info("cart.opened", { personId: "p-1" })
39
40
 
40
- describe("info", () => {
41
- it("stamps the line with the clock and writes it to stdout", async () => {
42
- const { service } = await build()
43
-
44
- service.info("thing.happened", { id: 1 })
45
-
46
- expect(stdout).toEqual([line({ level: "info", event: "thing.happened", time: TIME, id: 1 })])
47
- expect(stderr).toEqual([])
48
- })
41
+ expect(written).toEqual([
42
+ {
43
+ stream: "out",
44
+ line: `${JSON.stringify({ level: "info", event: "cart.opened", time: "2026-05-06T07:08:09.000Z", personId: "p-1" })}\n`,
45
+ },
46
+ ])
49
47
  })
50
48
 
51
- describe("warn", () => {
52
- it("writes the line to stderr, stamped with the moment of the call", async () => {
53
- const { service, clock } = await build()
54
- clock.advance(5_000)
49
+ it("writes a warn line without fields to the err stream", async () => {
50
+ const { logger, clock, written } = await build()
51
+ clock.advance(1000)
55
52
 
56
- service.warn("thing.slow")
53
+ logger.warn(LoggingLogEvent.StartupFailed)
57
54
 
58
- expect(stderr).toEqual([line({ level: "warn", event: "thing.slow", time: "2026-01-01T00:00:05.000Z" })])
59
- expect(stdout).toEqual([])
60
- })
55
+ expect(written).toEqual([
56
+ {
57
+ stream: "err",
58
+ line: `${JSON.stringify({ level: "warn", event: LoggingLogEvent.StartupFailed, time: "2026-05-06T07:08:10.000Z" })}\n`,
59
+ },
60
+ ])
61
61
  })
62
62
 
63
- describe("error", () => {
64
- it("serializes an Error cause by name and message", async () => {
65
- const { service } = await build()
63
+ it("writes an error line with the name and message of an Error cause", async () => {
64
+ const { logger, written } = await build()
66
65
 
67
- service.error("thing.failed", new TypeError("boom"), { id: 2 })
66
+ logger.error("db.failed", new TypeError("boom"), { operation: "PlaceOrderHandler" })
68
67
 
69
- expect(stderr).toEqual([
70
- line({
68
+ expect(written).toEqual([
69
+ {
70
+ stream: "err",
71
+ line: `${JSON.stringify({
71
72
  level: "error",
72
- event: "thing.failed",
73
- time: TIME,
73
+ event: "db.failed",
74
+ time: "2026-05-06T07:08:09.000Z",
74
75
  errorName: "TypeError",
75
76
  errorMessage: "boom",
76
- id: 2,
77
- }),
78
- ])
79
- })
77
+ operation: "PlaceOrderHandler",
78
+ })}\n`,
79
+ },
80
+ ])
81
+ })
80
82
 
81
- it("serializes a cause that is not an Error to its text", async () => {
82
- const { service } = await build()
83
+ it("writes an error line with the text of a cause that is not an Error", async () => {
84
+ const { logger, written } = await build()
83
85
 
84
- service.error("thing.failed", "plain text")
86
+ logger.error("db.failed", "plain failure")
85
87
 
86
- expect(stderr).toEqual([
87
- line({ level: "error", event: "thing.failed", time: TIME, errorMessage: "plain text" }),
88
- ])
89
- })
88
+ expect(written).toEqual([
89
+ {
90
+ stream: "err",
91
+ line: `${JSON.stringify({ level: "error", event: "db.failed", time: "2026-05-06T07:08:09.000Z", errorMessage: "plain failure" })}\n`,
92
+ },
93
+ ])
90
94
  })
91
95
 
92
96
  describe("createJsonLogger", () => {
93
- it("builds a logger stamped by the given clock", () => {
94
- createJsonLogger(new FakeClock(TIME)).info("thing.started")
97
+ afterEach(() => {
98
+ jest.restoreAllMocks()
99
+ })
95
100
 
96
- expect(stdout).toEqual([line({ level: "info", event: "thing.started", time: TIME })])
101
+ it("builds a logger that writes info to stdout and errors to stderr, stamped by the clock", () => {
102
+ const lines: Array<string> = []
103
+ jest.spyOn(process.stdout, "write").mockImplementation((chunk) => lines.push(`out:${String(chunk)}`) > 0)
104
+ jest.spyOn(process.stderr, "write").mockImplementation((chunk) => lines.push(`err:${String(chunk)}`) > 0)
105
+ const logger = createJsonLogger(new FakeClock("2026-05-06T07:08:09.000Z"))
106
+
107
+ logger.info(LoggingLogEvent.ServerStarted)
108
+ logger.warn(LoggingLogEvent.StartupFailed)
109
+
110
+ expect(lines).toEqual([
111
+ `out:${JSON.stringify({ level: "info", event: LoggingLogEvent.ServerStarted, time: "2026-05-06T07:08:09.000Z" })}\n`,
112
+ `err:${JSON.stringify({ level: "warn", event: LoggingLogEvent.StartupFailed, time: "2026-05-06T07:08:09.000Z" })}\n`,
113
+ ])
97
114
  })
98
115
  })
99
116
  })
@@ -1,23 +1,27 @@
1
- import { Injectable } from "@nestjs/common"
1
+ import type { Writable } from "node:stream"
2
2
  import { InjectClock } from "@modules/platform/clock"
3
3
  import type { Clock } from "@modules/platform/clock"
4
+ import { InjectLogErr, InjectLogOut } from "./logging.decorators"
4
5
  import type { LogFields, Logger } from "./logging.port"
5
6
 
6
7
  type Level = "info" | "warn" | "error"
7
8
 
8
- @Injectable()
9
- /** The default adapter: one JSON object per line, stamped by the Clock; info goes to stdout, warn and error to stderr. */
9
+ /** The default adapter: one JSON object per line, stamped by the Clock; info goes to `out`, warn and error to `err`. */
10
10
  export class JsonLoggerService implements Logger {
11
- constructor(@InjectClock() private readonly clock: Clock) {}
11
+ constructor(
12
+ @InjectClock() private readonly clock: Clock,
13
+ @InjectLogOut() private readonly out: Writable,
14
+ @InjectLogErr() private readonly err: Writable,
15
+ ) {}
12
16
 
13
17
  /** Writes an info line. */
14
18
  info(event: string, fields?: LogFields): void {
15
- this.write("info", process.stdout, event, fields)
19
+ this.write("info", this.out, event, fields)
16
20
  }
17
21
 
18
22
  /** Writes a warn line. */
19
23
  warn(event: string, fields?: LogFields): void {
20
- this.write("warn", process.stderr, event, fields)
24
+ this.write("warn", this.err, event, fields)
21
25
  }
22
26
 
23
27
  /** Writes an error line with the cause serialized by name and message. */
@@ -26,13 +30,13 @@ export class JsonLoggerService implements Logger {
26
30
  cause instanceof Error
27
31
  ? { errorName: cause.name, errorMessage: cause.message }
28
32
  : { errorMessage: String(cause) }
29
- this.write("error", process.stderr, event, { ...detail, ...fields })
33
+ this.write("error", this.err, event, { ...detail, ...fields })
30
34
  }
31
35
 
32
- private write(level: Level, sink: NodeJS.WriteStream, event: string, fields?: LogFields): void {
36
+ private write(level: Level, sink: Writable, event: string, fields?: LogFields): void {
33
37
  sink.write(`${JSON.stringify({ level, event, time: this.clock.now().toISOString(), ...fields })}\n`)
34
38
  }
35
39
  }
36
40
 
37
- /** Builds the JSON logger stamping lines with `clock`; main.ts uses it before the DI container exists. */
38
- export const createJsonLogger = (clock: Clock): Logger => new JsonLoggerService(clock)
41
+ /** Builds the JSON logger stamping lines with `clock` on stdout and stderr; main.ts uses it before the DI container exists. */
42
+ export const createJsonLogger = (clock: Clock): Logger => new JsonLoggerService(clock, process.stdout, process.stderr)
@@ -1,3 +1,4 @@
1
+ import type { Writable } from "node:stream"
1
2
  import { injector } from "@modules/platform/composition"
2
3
  import type { TypedParameterDecorator } from "@modules/platform/composition"
3
4
  import type { Logger } from "./logging.port"
@@ -7,3 +8,15 @@ export const LOGGER: unique symbol = Symbol("platform.logging.logger")
7
8
 
8
9
  /** Injects the Logger port. Parameter type: Logger. */
9
10
  export const InjectLogger = (): TypedParameterDecorator<Logger> => injector<Logger>(LOGGER)
11
+
12
+ /** Token of the stream info lines go to. */
13
+ export const LOG_OUT: unique symbol = Symbol("platform.logging.out")
14
+
15
+ /** Token of the stream warn and error lines go to. */
16
+ export const LOG_ERR: unique symbol = Symbol("platform.logging.err")
17
+
18
+ /** Injects the stream info lines go to. Parameter type: Writable. */
19
+ export const InjectLogOut = (): TypedParameterDecorator<Writable> => injector<Writable>(LOG_OUT)
20
+
21
+ /** Injects the stream warn and error lines go to. Parameter type: Writable. */
22
+ export const InjectLogErr = (): TypedParameterDecorator<Writable> => injector<Writable>(LOG_ERR)
@@ -4,4 +4,8 @@ export enum LoggingLogEvent {
4
4
  ServerStarted = "server.started",
5
5
  /** The service failed before it could serve; the process exits non-zero. */
6
6
  StartupFailed = "server.startup_failed",
7
+ /** The worker started: its jobs are ticking and its consumers are polling. */
8
+ WorkerStarted = "worker.started",
9
+ /** The migrate app finished; the names of the applied migrations ride in the fields. */
10
+ MigrationsApplied = "migrations.applied",
7
11
  }
@@ -1,6 +1,5 @@
1
1
  /** The structured data of one log line; values are plain data, never a request, a token or a secret. */
2
2
  export interface LogFields {
3
- /** The value logged under `name`. */
4
3
  readonly [name: string]: unknown
5
4
  }
6
5
 
@@ -0,0 +1,2 @@
1
+ export type { Outcome } from "./outcome.contracts"
2
+ export { ok, refused, unwrapOutcome } from "./outcome.mapper"
@@ -0,0 +1,25 @@
1
+ import type { DomainError, DomainErrorInit, ErrorParams } from "@modules/platform/errors"
2
+
3
+ /** The success half of an outcome: the value the operation produced. */
4
+ export interface OutcomeOk<V> {
5
+ /** Discriminant of the success half. */
6
+ readonly kind: "ok"
7
+ /** The value the operation produced. */
8
+ readonly value: V
9
+ }
10
+
11
+ /** The expected-refusal half of an outcome: a capability code and the parameters of its display text. */
12
+ export interface OutcomeRefused<C extends string> {
13
+ /** Discriminant of the refusal half. */
14
+ readonly kind: "refused"
15
+ /** The capability code of the refusal, a member of its code enum. */
16
+ readonly code: C
17
+ /** Values for the placeholders of the display text. */
18
+ readonly params?: ErrorParams
19
+ }
20
+
21
+ /** The result of an operation that can be refused for an expected business reason: returned, never thrown. */
22
+ export type Outcome<V, C extends string> = OutcomeOk<V> | OutcomeRefused<C>
23
+
24
+ /** The class of a capability error family, as `unwrapOutcome` constructs it from a refusal. */
25
+ export type OutcomeErrorClass<K extends string> = new (init: DomainErrorInit<K>) => DomainError<K>
@@ -0,0 +1,24 @@
1
+ import type { ErrorParams } from "@modules/platform/errors"
2
+ import type { Outcome, OutcomeErrorClass, OutcomeOk, OutcomeRefused } from "./outcome.contracts"
3
+
4
+ /** Wraps a produced value as a successful outcome. */
5
+ export const ok = <V>(value: V): OutcomeOk<V> => ({ kind: "ok", value })
6
+
7
+ /** Builds a refusal with a capability code and the parameters of its display text. */
8
+ export const refused = <C extends string>(code: C, params?: ErrorParams): OutcomeRefused<C> => ({
9
+ kind: "refused",
10
+ code,
11
+ params,
12
+ })
13
+
14
+ /**
15
+ * Returns the value of a successful outcome; a refusal becomes the capability error of `ErrorClass`. This is the only
16
+ * place a refusal turns into a throw, and it runs in the transport.
17
+ */
18
+ export const unwrapOutcome = <V, C extends K, K extends string>(
19
+ outcome: Outcome<V, C>,
20
+ ErrorClass: OutcomeErrorClass<K>,
21
+ ): V => {
22
+ if (outcome.kind === "ok") return outcome.value
23
+ throw new ErrorClass({ code: outcome.code, params: outcome.params })
24
+ }
@@ -0,0 +1,7 @@
1
+ const config = {
2
+ plugins: {
3
+ "@tailwindcss/postcss": {},
4
+ },
5
+ }
6
+
7
+ export default config
@@ -1,22 +1,10 @@
1
1
  "use client"
2
2
 
3
- import { useTranslations } from "next-intl"
3
+ import { ErrorPage } from "@/features/pages/ErrorPage"
4
4
 
5
- interface ErrorPageProps {
6
- readonly reset: () => void
7
- }
5
+ type ErrorProps = { readonly reset: () => void }
8
6
 
9
- /** Boundary of the locale segment: a render failure shows this instead of a blank page, and retry re-renders the segment. */
10
- const ErrorPage = ({ reset }: ErrorPageProps) => {
11
- const t = useTranslations("errors.page")
12
- return (
13
- <main role="alert">
14
- <h1>{t("title")}</h1>
15
- <button type="button" onClick={reset}>
16
- {t("retry")}
17
- </button>
18
- </main>
19
- )
20
- }
7
+ /** The locale segment's error boundary slot: it mounts the error page and hands it the segment's retry. */
8
+ const Error = (props: ErrorProps) => <ErrorPage onRetry={props.reset} />
21
9
 
22
- export default ErrorPage
10
+ export default Error
@@ -1,31 +1,20 @@
1
- import { hasLocale, NextIntlClientProvider } from "next-intl"
2
- import { getMessages, setRequestLocale } from "next-intl/server"
3
- import { notFound } from "next/navigation"
1
+ import type { Metadata } from "next"
4
2
  import type { ReactNode } from "react"
5
- import { routing } from "../../modules/i18n"
3
+ import { LocaleShell, localeMetadata } from "@/features/layouts/LocaleShell"
6
4
  import "../globals.css"
7
5
 
8
- interface LocaleLayoutProps {
6
+ type LayoutProps = {
9
7
  readonly children: ReactNode
10
8
  readonly params: Promise<{ readonly locale: string }>
11
9
  }
12
10
 
13
- /** One static shell per served locale. */
14
- export const generateStaticParams = () => routing.locales.map((locale) => ({ locale }))
11
+ /** The localized document title of this route segment. */
12
+ export const generateMetadata = async (props: LayoutProps): Promise<Metadata> =>
13
+ localeMetadata((await props.params).locale)
15
14
 
16
- /** Root layout: sets `<html lang>` from the route segment and gives client components only the `errors` namespace. */
17
- const LocaleLayout = async ({ children, params }: LocaleLayoutProps) => {
18
- const { locale } = await params
19
- if (!hasLocale(routing.locales, locale)) notFound()
20
- setRequestLocale(locale)
21
- const messages = await getMessages()
22
- return (
23
- <html lang={locale}>
24
- <body className="min-h-dvh">
25
- <NextIntlClientProvider messages={{ errors: messages.errors }}>{children}</NextIntlClientProvider>
26
- </body>
27
- </html>
28
- )
29
- }
15
+ /** The locale segment's layout slot: it mounts the locale shell around the segment. */
16
+ const Layout = async (props: LayoutProps) => (
17
+ <LocaleShell lang={(await props.params).locale}>{props.children}</LocaleShell>
18
+ )
30
19
 
31
- export default LocaleLayout
20
+ export default Layout
@@ -0,0 +1,6 @@
1
+ import { LoadingPage } from "@/features/pages/LoadingPage"
2
+
3
+ /** The locale segment's loading slot: it mounts the loading page and nothing else. */
4
+ const Loading = () => <LoadingPage />
5
+
6
+ export default Loading
@@ -1,15 +1,6 @@
1
- import { getTranslations } from "next-intl/server"
2
- import { Link } from "../../modules/i18n"
1
+ import { NotFoundPage } from "@/features/pages/NotFoundPage"
3
2
 
4
- /** Shown when a route calls `notFound()`. */
5
- const NotFound = async () => {
6
- const t = await getTranslations("notFound")
7
- return (
8
- <main>
9
- <h1>{t("title")}</h1>
10
- <Link href="/">{t("home")}</Link>
11
- </main>
12
- )
13
- }
3
+ /** The locale segment's not-found slot: it mounts the not-found page and nothing else. */
4
+ const NotFound = () => <NotFoundPage />
14
5
 
15
6
  export default NotFound
@@ -1,27 +1,9 @@
1
- import type { Metadata } from "next"
2
- import { getTranslations, setRequestLocale } from "next-intl/server"
1
+ import { HomePage, homeMetadata } from "@/features/pages/HomePage"
3
2
 
4
- interface HomePageProps {
5
- readonly params: Promise<{ readonly locale: string }>
6
- }
3
+ /** The document title of this page, from the catalog of the requested locale. */
4
+ export const generateMetadata = homeMetadata
7
5
 
8
- /** The document title comes from the catalog of the requested locale. */
9
- export const generateMetadata = async ({ params }: HomePageProps): Promise<Metadata> => {
10
- const { locale } = await params
11
- const t = await getTranslations({ locale, namespace: "home" })
12
- return { title: t("title") }
13
- }
6
+ /** The locale root's page slot: it mounts the home page and nothing else. */
7
+ const Page = () => <HomePage />
14
8
 
15
- /** The first page of the app; a server component, so no catalog is shipped for it. */
16
- const HomePage = async ({ params }: HomePageProps) => {
17
- const { locale } = await params
18
- setRequestLocale(locale)
19
- const t = await getTranslations("home")
20
- return (
21
- <main>
22
- <h1>{t("title")}</h1>
23
- </main>
24
- )
25
- }
26
-
27
- export default HomePage
9
+ export default Page
@@ -1,24 +1,10 @@
1
1
  "use client"
2
2
 
3
- import { DEFAULT_LOCALE } from "../modules/i18n"
4
- import messages from "../modules/i18n/messages/vi.json"
3
+ import { GlobalErrorPage } from "@/features/pages/GlobalErrorPage"
5
4
 
6
- interface GlobalErrorProps {
7
- readonly reset: () => void
8
- }
5
+ type GlobalErrorProps = { readonly reset: () => void }
9
6
 
10
- /** Last-resort boundary above the locale layout: it has no provider, so it reads the default catalog directly. */
11
- const GlobalError = ({ reset }: GlobalErrorProps) => (
12
- <html lang={DEFAULT_LOCALE}>
13
- <body>
14
- <main role="alert">
15
- <h1>{messages.errors.global.title}</h1>
16
- <button type="button" onClick={reset}>
17
- {messages.errors.global.retry}
18
- </button>
19
- </main>
20
- </body>
21
- </html>
22
- )
7
+ /** The last-resort error boundary slot: it mounts the global error page and hands it the retry. */
8
+ const GlobalError = (props: GlobalErrorProps) => <GlobalErrorPage onRetry={props.reset} />
23
9
 
24
10
  export default GlobalError