@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.
- package/CHANGELOG.md +22 -0
- package/README.md +25 -21
- package/bin/hfs.mjs +59 -37
- package/lint/run.mjs +70 -39
- package/package.json +2 -2
- package/runtime/engine/admission.mjs +3 -3
- package/runtime/engine/ledger-db.mjs +2 -2
- package/runtime/engine/machine-db.mjs +90 -9
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/modules/kernel/failure-codes.yaml +23 -32
- package/runtime/scripts/checks/architecture/backend.mjs +1 -1
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-check.mjs +156 -141
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
- package/runtime/scripts/lib/hfs-rules/deps.mjs +4 -2
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
- package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/safe-remove.mjs +95 -10
- package/scaffold/app.mjs +205 -0
- package/scaffold/service.mjs +26 -16
- package/sync/cli.mjs +1 -1
- package/sync/hygiene.mjs +11 -8
- package/sync/index.mjs +109 -111
- package/sync/managed.mjs +9 -8
- package/sync/sonar-key.mjs +20 -22
- package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
- package/templates/app/gitignore +6 -0
- package/templates/app/hooks/husky/pre-commit +25 -0
- package/templates/app/hooks/husky/pre-push +7 -0
- package/templates/app/package-scripts/package.json +22 -0
- package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
- package/templates/app/skeleton/.editorconfig +15 -0
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton/.nvmrc +1 -0
- package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
- package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton/README.md +36 -0
- package/templates/app/skeleton/scripts/codegen.mjs +4 -0
- package/templates/{fe → app}/tool-config/prettierignore +4 -1
- package/templates/be/skeleton/.sops.yaml +2 -0
- package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
- package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
- package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
- package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
- package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
- package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
- package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
- package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
- package/sync/skeleton.mjs +0 -76
- package/templates/be/gitignore +0 -2
- package/templates/be/hooks/husky/pre-commit +0 -13
- package/templates/be/hooks/husky/pre-push +0 -6
- package/templates/be/package-scripts/package.json +0 -19
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
- package/templates/be/tool-config/prettierignore +0 -8
- package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
- package/templates/fe/gitignore +0 -3
- package/templates/fe/hooks/husky/pre-commit +0 -16
- package/templates/fe/hooks/husky/pre-push +0 -5
- package/templates/fe/package-scripts/package.json +0 -13
- package/templates/fe/parts/api-client.ts +0 -44
- package/templates/fe/parts/api-outcome.ts +0 -7
- package/templates/fe/quality-config/sonar-project.properties +0 -8
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
- package/templates/fe/tool-config/prettierrc +0 -1
- /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
- /package/templates/{be → app}/starciwork.gitignore +0 -0
- /package/templates/{be → app}/tool-config/prettierrc +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /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,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 {
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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: [
|
|
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 {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
37
|
-
jest.restoreAllMocks()
|
|
38
|
-
})
|
|
39
|
+
logger.info("cart.opened", { personId: "p-1" })
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
53
|
+
logger.warn(LoggingLogEvent.StartupFailed)
|
|
57
54
|
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
66
|
+
logger.error("db.failed", new TypeError("boom"), { operation: "PlaceOrderHandler" })
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
expect(written).toEqual([
|
|
69
|
+
{
|
|
70
|
+
stream: "err",
|
|
71
|
+
line: `${JSON.stringify({
|
|
71
72
|
level: "error",
|
|
72
|
-
event: "
|
|
73
|
-
time:
|
|
73
|
+
event: "db.failed",
|
|
74
|
+
time: "2026-05-06T07:08:09.000Z",
|
|
74
75
|
errorName: "TypeError",
|
|
75
76
|
errorMessage: "boom",
|
|
76
|
-
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
operation: "PlaceOrderHandler",
|
|
78
|
+
})}\n`,
|
|
79
|
+
},
|
|
80
|
+
])
|
|
81
|
+
})
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
+
logger.error("db.failed", "plain failure")
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
jest.restoreAllMocks()
|
|
99
|
+
})
|
|
95
100
|
|
|
96
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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",
|
|
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",
|
|
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",
|
|
33
|
+
this.write("error", this.err, event, { ...detail, ...fields })
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
private write(level: Level, sink:
|
|
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
|
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { ErrorPage } from "@/features/pages/ErrorPage"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
readonly reset: () => void
|
|
7
|
-
}
|
|
5
|
+
type ErrorProps = { readonly reset: () => void }
|
|
8
6
|
|
|
9
|
-
/**
|
|
10
|
-
const
|
|
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
|
|
10
|
+
export default Error
|
|
@@ -1,31 +1,20 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
3
|
+
import { LocaleShell, localeMetadata } from "@/features/layouts/LocaleShell"
|
|
6
4
|
import "../globals.css"
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
type LayoutProps = {
|
|
9
7
|
readonly children: ReactNode
|
|
10
8
|
readonly params: Promise<{ readonly locale: string }>
|
|
11
9
|
}
|
|
12
10
|
|
|
13
|
-
/**
|
|
14
|
-
export const
|
|
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
|
-
/**
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
20
|
+
export default Layout
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Link } from "../../modules/i18n"
|
|
1
|
+
import { NotFoundPage } from "@/features/pages/NotFoundPage"
|
|
3
2
|
|
|
4
|
-
/**
|
|
5
|
-
const NotFound =
|
|
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
|
|
2
|
-
import { getTranslations, setRequestLocale } from "next-intl/server"
|
|
1
|
+
import { HomePage, homeMetadata } from "@/features/pages/HomePage"
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
/** The document title of this page, from the catalog of the requested locale. */
|
|
4
|
+
export const generateMetadata = homeMetadata
|
|
7
5
|
|
|
8
|
-
/** The
|
|
9
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
import messages from "../modules/i18n/messages/vi.json"
|
|
3
|
+
import { GlobalErrorPage } from "@/features/pages/GlobalErrorPage"
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
readonly reset: () => void
|
|
8
|
-
}
|
|
5
|
+
type GlobalErrorProps = { readonly reset: () => void }
|
|
9
6
|
|
|
10
|
-
/**
|
|
11
|
-
const GlobalError = (
|
|
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
|