@endora-commerce/cli 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/dist/bin/endora.d.ts +28 -0
- package/dist/bin/endora.d.ts.map +1 -0
- package/dist/bin/endora.js +926 -0
- package/dist/bin/endora.js.map +1 -0
- package/dist/check/estate.d.ts +189 -0
- package/dist/check/estate.d.ts.map +1 -0
- package/dist/check/estate.js +1037 -0
- package/dist/check/estate.js.map +1 -0
- package/dist/check/hosts.d.ts +25 -0
- package/dist/check/hosts.d.ts.map +1 -0
- package/dist/check/hosts.js +1347 -0
- package/dist/check/hosts.js.map +1 -0
- package/dist/check/index.d.ts +60 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +111 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/layout.d.ts +136 -0
- package/dist/check/layout.d.ts.map +1 -0
- package/dist/check/layout.js +262 -0
- package/dist/check/layout.js.map +1 -0
- package/dist/check/ledger.d.ts +98 -0
- package/dist/check/ledger.d.ts.map +1 -0
- package/dist/check/ledger.js +173 -0
- package/dist/check/ledger.js.map +1 -0
- package/dist/check/peer-owners.d.ts +116 -0
- package/dist/check/peer-owners.d.ts.map +1 -0
- package/dist/check/peer-owners.js +225 -0
- package/dist/check/peer-owners.js.map +1 -0
- package/dist/check/report.d.ts +33 -0
- package/dist/check/report.d.ts.map +1 -0
- package/dist/check/report.js +107 -0
- package/dist/check/report.js.map +1 -0
- package/dist/check/run.d.ts +147 -0
- package/dist/check/run.d.ts.map +1 -0
- package/dist/check/run.js +111 -0
- package/dist/check/run.js.map +1 -0
- package/dist/checks.d.ts +17 -0
- package/dist/checks.d.ts.map +1 -0
- package/dist/checks.js +17 -0
- package/dist/checks.js.map +1 -0
- package/dist/dev/index.d.ts +83 -0
- package/dist/dev/index.d.ts.map +1 -0
- package/dist/dev/index.js +298 -0
- package/dist/dev/index.js.map +1 -0
- package/dist/generate/divergence.d.ts +38 -0
- package/dist/generate/divergence.d.ts.map +1 -0
- package/dist/generate/divergence.js +237 -0
- package/dist/generate/divergence.js.map +1 -0
- package/dist/generate/index.d.ts +90 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +369 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/inputs/declaration.d.ts +18 -0
- package/dist/inputs/declaration.d.ts.map +1 -0
- package/dist/inputs/declaration.js +64 -0
- package/dist/inputs/declaration.js.map +1 -0
- package/dist/inputs/env-file.d.ts +73 -0
- package/dist/inputs/env-file.d.ts.map +1 -0
- package/dist/inputs/env-file.js +134 -0
- package/dist/inputs/env-file.js.map +1 -0
- package/dist/inputs/prompt.d.ts +21 -0
- package/dist/inputs/prompt.d.ts.map +1 -0
- package/dist/inputs/prompt.js +59 -0
- package/dist/inputs/prompt.js.map +1 -0
- package/dist/inputs/resolve.d.ts +163 -0
- package/dist/inputs/resolve.d.ts.map +1 -0
- package/dist/inputs/resolve.js +290 -0
- package/dist/inputs/resolve.js.map +1 -0
- package/dist/install/host.d.ts +27 -0
- package/dist/install/host.d.ts.map +1 -0
- package/dist/install/host.js +90 -0
- package/dist/install/host.js.map +1 -0
- package/dist/install/index.d.ts +173 -0
- package/dist/install/index.d.ts.map +1 -0
- package/dist/install/index.js +793 -0
- package/dist/install/index.js.map +1 -0
- package/dist/install/wizard.d.ts +144 -0
- package/dist/install/wizard.d.ts.map +1 -0
- package/dist/install/wizard.js +362 -0
- package/dist/install/wizard.js.map +1 -0
- package/dist/lib/admin-artefacts.d.ts +70 -0
- package/dist/lib/admin-artefacts.d.ts.map +1 -0
- package/dist/lib/admin-artefacts.js +354 -0
- package/dist/lib/admin-artefacts.js.map +1 -0
- package/dist/lib/admin-surfaces.d.ts +298 -0
- package/dist/lib/admin-surfaces.d.ts.map +1 -0
- package/dist/lib/admin-surfaces.js +669 -0
- package/dist/lib/admin-surfaces.js.map +1 -0
- package/dist/lib/delegated-composer.d.ts +85 -0
- package/dist/lib/delegated-composer.d.ts.map +1 -0
- package/dist/lib/delegated-composer.js +241 -0
- package/dist/lib/delegated-composer.js.map +1 -0
- package/dist/lib/divergence-artefacts.d.ts +305 -0
- package/dist/lib/divergence-artefacts.d.ts.map +1 -0
- package/dist/lib/divergence-artefacts.js +828 -0
- package/dist/lib/divergence-artefacts.js.map +1 -0
- package/dist/lib/divergence.d.ts +337 -0
- package/dist/lib/divergence.d.ts.map +1 -0
- package/dist/lib/divergence.js +1005 -0
- package/dist/lib/divergence.js.map +1 -0
- package/dist/lib/docs-artefacts.d.ts +395 -0
- package/dist/lib/docs-artefacts.d.ts.map +1 -0
- package/dist/lib/docs-artefacts.js +781 -0
- package/dist/lib/docs-artefacts.js.map +1 -0
- package/dist/lib/emitted-exports.d.ts +21 -0
- package/dist/lib/emitted-exports.d.ts.map +1 -0
- package/dist/lib/emitted-exports.js +96 -0
- package/dist/lib/emitted-exports.js.map +1 -0
- package/dist/lib/emitted-freshness.d.ts +112 -0
- package/dist/lib/emitted-freshness.d.ts.map +1 -0
- package/dist/lib/emitted-freshness.js +288 -0
- package/dist/lib/emitted-freshness.js.map +1 -0
- package/dist/lib/entity-index-artefact.d.ts +95 -0
- package/dist/lib/entity-index-artefact.d.ts.map +1 -0
- package/dist/lib/entity-index-artefact.js +210 -0
- package/dist/lib/entity-index-artefact.js.map +1 -0
- package/dist/lib/instance-build-inputs.d.ts +108 -0
- package/dist/lib/instance-build-inputs.d.ts.map +1 -0
- package/dist/lib/instance-build-inputs.js +165 -0
- package/dist/lib/instance-build-inputs.js.map +1 -0
- package/dist/lib/module-docs.d.ts +473 -0
- package/dist/lib/module-docs.d.ts.map +1 -0
- package/dist/lib/module-docs.js +711 -0
- package/dist/lib/module-docs.js.map +1 -0
- package/dist/lib/module-package-subpaths.d.ts +56 -0
- package/dist/lib/module-package-subpaths.d.ts.map +1 -0
- package/dist/lib/module-package-subpaths.js +223 -0
- package/dist/lib/module-package-subpaths.js.map +1 -0
- package/dist/lib/module-packages.d.ts +200 -0
- package/dist/lib/module-packages.d.ts.map +1 -0
- package/dist/lib/module-packages.js +580 -0
- package/dist/lib/module-packages.js.map +1 -0
- package/dist/lib/module-population.d.ts +129 -0
- package/dist/lib/module-population.d.ts.map +1 -0
- package/dist/lib/module-population.js +172 -0
- package/dist/lib/module-population.js.map +1 -0
- package/dist/lib/module-roots.d.ts +337 -0
- package/dist/lib/module-roots.d.ts.map +1 -0
- package/dist/lib/module-roots.js +586 -0
- package/dist/lib/module-roots.js.map +1 -0
- package/dist/lib/nested-checkouts.d.ts +33 -0
- package/dist/lib/nested-checkouts.d.ts.map +1 -0
- package/dist/lib/nested-checkouts.js +160 -0
- package/dist/lib/nested-checkouts.js.map +1 -0
- package/dist/lib/platform-root.d.ts +43 -0
- package/dist/lib/platform-root.d.ts.map +1 -0
- package/dist/lib/platform-root.js +134 -0
- package/dist/lib/platform-root.js.map +1 -0
- package/dist/lib/platform-surface.d.ts +235 -0
- package/dist/lib/platform-surface.d.ts.map +1 -0
- package/dist/lib/platform-surface.js +393 -0
- package/dist/lib/platform-surface.js.map +1 -0
- package/dist/lib/port-registrations.d.ts +223 -0
- package/dist/lib/port-registrations.d.ts.map +1 -0
- package/dist/lib/port-registrations.js +532 -0
- package/dist/lib/port-registrations.js.map +1 -0
- package/dist/lib/read-size.d.ts +154 -0
- package/dist/lib/read-size.d.ts.map +1 -0
- package/dist/lib/read-size.js +182 -0
- package/dist/lib/read-size.js.map +1 -0
- package/dist/lib/registration-owners.d.ts +79 -0
- package/dist/lib/registration-owners.d.ts.map +1 -0
- package/dist/lib/registration-owners.js +77 -0
- package/dist/lib/registration-owners.js.map +1 -0
- package/dist/lib/release-index.d.ts +53 -0
- package/dist/lib/release-index.d.ts.map +1 -0
- package/dist/lib/release-index.js +162 -0
- package/dist/lib/release-index.js.map +1 -0
- package/dist/lib/repeating-timers.d.ts +79 -0
- package/dist/lib/repeating-timers.d.ts.map +1 -0
- package/dist/lib/repeating-timers.js +189 -0
- package/dist/lib/repeating-timers.js.map +1 -0
- package/dist/lib/source-text.d.ts +34 -0
- package/dist/lib/source-text.d.ts.map +1 -0
- package/dist/lib/source-text.js +80 -0
- package/dist/lib/source-text.js.map +1 -0
- package/dist/lib/specifiers.d.ts +20 -0
- package/dist/lib/specifiers.d.ts.map +1 -0
- package/dist/lib/specifiers.js +130 -0
- package/dist/lib/specifiers.js.map +1 -0
- package/dist/lib/sql-tables.d.ts +166 -0
- package/dist/lib/sql-tables.d.ts.map +1 -0
- package/dist/lib/sql-tables.js +464 -0
- package/dist/lib/sql-tables.js.map +1 -0
- package/dist/lib/switchable-modules.d.ts +54 -0
- package/dist/lib/switchable-modules.d.ts.map +1 -0
- package/dist/lib/switchable-modules.js +104 -0
- package/dist/lib/switchable-modules.js.map +1 -0
- package/dist/lib/tailwind-sources.d.ts +136 -0
- package/dist/lib/tailwind-sources.d.ts.map +1 -0
- package/dist/lib/tailwind-sources.js +307 -0
- package/dist/lib/tailwind-sources.js.map +1 -0
- package/dist/lib/ui-layer.d.ts +54 -0
- package/dist/lib/ui-layer.d.ts.map +1 -0
- package/dist/lib/ui-layer.js +57 -0
- package/dist/lib/ui-layer.js.map +1 -0
- package/dist/lib/workspace-packages.d.ts +186 -0
- package/dist/lib/workspace-packages.d.ts.map +1 -0
- package/dist/lib/workspace-packages.js +351 -0
- package/dist/lib/workspace-packages.js.map +1 -0
- package/dist/new-instance/deploy.d.ts +211 -0
- package/dist/new-instance/deploy.d.ts.map +1 -0
- package/dist/new-instance/deploy.js +1381 -0
- package/dist/new-instance/deploy.js.map +1 -0
- package/dist/new-instance/docs-toolchain.d.ts +66 -0
- package/dist/new-instance/docs-toolchain.d.ts.map +1 -0
- package/dist/new-instance/docs-toolchain.js +69 -0
- package/dist/new-instance/docs-toolchain.js.map +1 -0
- package/dist/new-instance/host.d.ts +124 -0
- package/dist/new-instance/host.d.ts.map +1 -0
- package/dist/new-instance/host.js +276 -0
- package/dist/new-instance/host.js.map +1 -0
- package/dist/new-instance/index.d.ts +118 -0
- package/dist/new-instance/index.d.ts.map +1 -0
- package/dist/new-instance/index.js +567 -0
- package/dist/new-instance/index.js.map +1 -0
- package/dist/new-instance/modules.d.ts +188 -0
- package/dist/new-instance/modules.d.ts.map +1 -0
- package/dist/new-instance/modules.js +392 -0
- package/dist/new-instance/modules.js.map +1 -0
- package/dist/new-instance/template.d.ts +505 -0
- package/dist/new-instance/template.d.ts.map +1 -0
- package/dist/new-instance/template.js +1886 -0
- package/dist/new-instance/template.js.map +1 -0
- package/dist/new-module/emit.d.ts +67 -0
- package/dist/new-module/emit.d.ts.map +1 -0
- package/dist/new-module/emit.js +1393 -0
- package/dist/new-module/emit.js.map +1 -0
- package/dist/new-module/host.d.ts +67 -0
- package/dist/new-module/host.d.ts.map +1 -0
- package/dist/new-module/host.js +224 -0
- package/dist/new-module/host.js.map +1 -0
- package/dist/new-module/index.d.ts +32 -0
- package/dist/new-module/index.d.ts.map +1 -0
- package/dist/new-module/index.js +193 -0
- package/dist/new-module/index.js.map +1 -0
- package/dist/new-module/spec.d.ts +188 -0
- package/dist/new-module/spec.d.ts.map +1 -0
- package/dist/new-module/spec.js +404 -0
- package/dist/new-module/spec.js.map +1 -0
- package/dist/new-module/text.d.ts +15 -0
- package/dist/new-module/text.d.ts.map +1 -0
- package/dist/new-module/text.js +22 -0
- package/dist/new-module/text.js.map +1 -0
- package/dist/new-storefront/dockerfile.d.ts +20 -0
- package/dist/new-storefront/dockerfile.d.ts.map +1 -0
- package/dist/new-storefront/dockerfile.js +131 -0
- package/dist/new-storefront/dockerfile.js.map +1 -0
- package/dist/new-storefront/gitignore.d.ts +23 -0
- package/dist/new-storefront/gitignore.d.ts.map +1 -0
- package/dist/new-storefront/gitignore.js +39 -0
- package/dist/new-storefront/gitignore.js.map +1 -0
- package/dist/new-storefront/index.d.ts +93 -0
- package/dist/new-storefront/index.d.ts.map +1 -0
- package/dist/new-storefront/index.js +329 -0
- package/dist/new-storefront/index.js.map +1 -0
- package/dist/new-storefront/npmrc.d.ts +98 -0
- package/dist/new-storefront/npmrc.d.ts.map +1 -0
- package/dist/new-storefront/npmrc.js +217 -0
- package/dist/new-storefront/npmrc.js.map +1 -0
- package/dist/new-storefront/reference.d.ts +189 -0
- package/dist/new-storefront/reference.d.ts.map +1 -0
- package/dist/new-storefront/reference.js +430 -0
- package/dist/new-storefront/reference.js.map +1 -0
- package/dist/new-storefront/rewrite.d.ts +171 -0
- package/dist/new-storefront/rewrite.d.ts.map +1 -0
- package/dist/new-storefront/rewrite.js +701 -0
- package/dist/new-storefront/rewrite.js.map +1 -0
- package/dist/release-index.json +284 -0
- package/dist/rules/action-route-permissions.d.ts +141 -0
- package/dist/rules/action-route-permissions.d.ts.map +1 -0
- package/dist/rules/action-route-permissions.js +556 -0
- package/dist/rules/action-route-permissions.js.map +1 -0
- package/dist/rules/bundle-pairing.d.ts +74 -0
- package/dist/rules/bundle-pairing.d.ts.map +1 -0
- package/dist/rules/bundle-pairing.js +281 -0
- package/dist/rules/bundle-pairing.js.map +1 -0
- package/dist/rules/channel-resolution.d.ts +17 -0
- package/dist/rules/channel-resolution.d.ts.map +1 -0
- package/dist/rules/channel-resolution.js +382 -0
- package/dist/rules/channel-resolution.js.map +1 -0
- package/dist/rules/command-coverage.d.ts +210 -0
- package/dist/rules/command-coverage.d.ts.map +1 -0
- package/dist/rules/command-coverage.js +714 -0
- package/dist/rules/command-coverage.js.map +1 -0
- package/dist/rules/container-imports.d.ts +60 -0
- package/dist/rules/container-imports.d.ts.map +1 -0
- package/dist/rules/container-imports.js +158 -0
- package/dist/rules/container-imports.js.map +1 -0
- package/dist/rules/default-language-prose.d.ts +212 -0
- package/dist/rules/default-language-prose.d.ts.map +1 -0
- package/dist/rules/default-language-prose.js +710 -0
- package/dist/rules/default-language-prose.js.map +1 -0
- package/dist/rules/diacritic-folds.d.ts +238 -0
- package/dist/rules/diacritic-folds.d.ts.map +1 -0
- package/dist/rules/diacritic-folds.js +681 -0
- package/dist/rules/diacritic-folds.js.map +1 -0
- package/dist/rules/entity-tenant-classification.d.ts +171 -0
- package/dist/rules/entity-tenant-classification.d.ts.map +1 -0
- package/dist/rules/entity-tenant-classification.js +323 -0
- package/dist/rules/entity-tenant-classification.js.map +1 -0
- package/dist/rules/entry-presence.d.ts +142 -0
- package/dist/rules/entry-presence.d.ts.map +1 -0
- package/dist/rules/entry-presence.js +339 -0
- package/dist/rules/entry-presence.js.map +1 -0
- package/dist/rules/entry-scope.d.ts +91 -0
- package/dist/rules/entry-scope.d.ts.map +1 -0
- package/dist/rules/entry-scope.js +404 -0
- package/dist/rules/entry-scope.js.map +1 -0
- package/dist/rules/env-inputs.d.ts +222 -0
- package/dist/rules/env-inputs.d.ts.map +1 -0
- package/dist/rules/env-inputs.js +951 -0
- package/dist/rules/env-inputs.js.map +1 -0
- package/dist/rules/kernel-boundary.d.ts +37 -0
- package/dist/rules/kernel-boundary.d.ts.map +1 -0
- package/dist/rules/kernel-boundary.js +195 -0
- package/dist/rules/kernel-boundary.js.map +1 -0
- package/dist/rules/nul-bytes.d.ts +233 -0
- package/dist/rules/nul-bytes.d.ts.map +1 -0
- package/dist/rules/nul-bytes.js +332 -0
- package/dist/rules/nul-bytes.js.map +1 -0
- package/dist/rules/platform-surface.d.ts +479 -0
- package/dist/rules/platform-surface.d.ts.map +1 -0
- package/dist/rules/platform-surface.js +749 -0
- package/dist/rules/platform-surface.js.map +1 -0
- package/dist/rules/port-catches.d.ts +225 -0
- package/dist/rules/port-catches.d.ts.map +1 -0
- package/dist/rules/port-catches.js +1374 -0
- package/dist/rules/port-catches.js.map +1 -0
- package/dist/rules/port-shape.d.ts +213 -0
- package/dist/rules/port-shape.d.ts.map +1 -0
- package/dist/rules/port-shape.js +670 -0
- package/dist/rules/port-shape.js.map +1 -0
- package/dist/rules/queue-names.d.ts +108 -0
- package/dist/rules/queue-names.d.ts.map +1 -0
- package/dist/rules/queue-names.js +395 -0
- package/dist/rules/queue-names.js.map +1 -0
- package/dist/rules/singleton-identity.d.ts +205 -0
- package/dist/rules/singleton-identity.d.ts.map +1 -0
- package/dist/rules/singleton-identity.js +830 -0
- package/dist/rules/singleton-identity.js.map +1 -0
- package/dist/rules/subscribe-seam.d.ts +121 -0
- package/dist/rules/subscribe-seam.d.ts.map +1 -0
- package/dist/rules/subscribe-seam.js +594 -0
- package/dist/rules/subscribe-seam.js.map +1 -0
- package/dist/rules/transaction-context.d.ts +40 -0
- package/dist/rules/transaction-context.d.ts.map +1 -0
- package/dist/rules/transaction-context.js +294 -0
- package/dist/rules/transaction-context.js.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,1393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `endora new module` writes (`contracts/module-scaffold-output.md` §1).
|
|
3
|
+
*
|
|
4
|
+
* The template is **code that composes files**, not a directory of files with
|
|
5
|
+
* placeholders (`research.md` §7): most of what a module package holds is
|
|
6
|
+
* conditional on the author's answers, and a placeholder language inside `.ts`
|
|
7
|
+
* files is a language neither `tsc` nor `eslint` can read. What is emitted here
|
|
8
|
+
* is ordinary source, compiled by this package's own build and asserted by tests
|
|
9
|
+
* over the real output.
|
|
10
|
+
*
|
|
11
|
+
* **`package.json` is not in this file, deliberately.** It has an author
|
|
12
|
+
* already — the platform's manifest generator — and a second one is D-100's
|
|
13
|
+
* subject. See `manifest-render.ts`.
|
|
14
|
+
*/
|
|
15
|
+
import { adminApiPathFor, adminRoutesOf, adminScreenRouteOf, camelOf, camelOfActionId, gatingPermissionOf, manifestObjectFor, navLabelKeyOf, pageComponentOf, pascalOf, segmentOf, slugOf, TENANT_SCOPE_DECORATORS, } from './spec.js';
|
|
16
|
+
import { flatJson, quote, rootPrefixFor } from './text.js';
|
|
17
|
+
/**
|
|
18
|
+
* Every file except `package.json`, in the order they are written.
|
|
19
|
+
*
|
|
20
|
+
* `depth` is how far the target directory sits below the checkout root, which is
|
|
21
|
+
* what the two tsconfigs' `extends` and the vitest configuration's base import
|
|
22
|
+
* are relative to. It is passed in rather than computed here so the emitter
|
|
23
|
+
* stays a pure function of the specification plus that one fact.
|
|
24
|
+
*/
|
|
25
|
+
export function emitModuleFiles(spec, depth) {
|
|
26
|
+
const files = [
|
|
27
|
+
{ path: 'tsconfig.json', content: typecheckConfig(spec, depth) },
|
|
28
|
+
{ path: 'tsconfig.build.json', content: buildConfig() },
|
|
29
|
+
];
|
|
30
|
+
if (spec.layers.admin !== null) {
|
|
31
|
+
files.push({ path: 'tsconfig.ui.json', content: uiConfig() });
|
|
32
|
+
}
|
|
33
|
+
files.push({ path: 'vitest.config.ts', content: vitestConfig(spec, depth) }, { path: 'src/manifest.ts', content: moduleManifest(spec) }, { path: 'src/backend/index.ts', content: backendIndex(spec) }, { path: `src/backend/services/${slugOf(spec.id)}.service.ts`, content: service(spec) });
|
|
34
|
+
if (spec.permissions.length > 0) {
|
|
35
|
+
files.push({ path: 'src/backend/routes.admin.ts', content: adminRoutes(spec) });
|
|
36
|
+
}
|
|
37
|
+
if (spec.layers.entities) {
|
|
38
|
+
files.push({ path: `src/backend/entities/${entityFileOf(spec)}`, content: entity(spec) });
|
|
39
|
+
files.push({ path: `src/migrations/${migrationFileOf(spec)}`, content: migration(spec) });
|
|
40
|
+
files.push({ path: 'src/migrations/index.ts', content: migrationsIndex(spec) });
|
|
41
|
+
}
|
|
42
|
+
if (spec.layers.worker) {
|
|
43
|
+
files.push({ path: `src/backend/workers/${slugOf(spec.id)}.worker.ts`, content: worker(spec) });
|
|
44
|
+
}
|
|
45
|
+
if (spec.layers.ports) {
|
|
46
|
+
files.push({ path: 'src/ports/index.ts', content: ports(spec) });
|
|
47
|
+
}
|
|
48
|
+
if (spec.layers.admin !== null) {
|
|
49
|
+
files.push({ path: 'src/admin/index.ts', content: adminIndex(spec) });
|
|
50
|
+
files.push({ path: `src/admin/pages/${pageComponentOf(spec)}.tsx`, content: adminPage(spec) });
|
|
51
|
+
}
|
|
52
|
+
files.push({ path: 'i18n/en.json', content: bundle(spec) });
|
|
53
|
+
files.push({ path: 'i18n/pl.json', content: bundle(spec) });
|
|
54
|
+
files.push({ path: 'test/unit/manifest.test.ts', content: manifestTest(spec) });
|
|
55
|
+
if (spec.layers.admin !== null) {
|
|
56
|
+
files.push({ path: 'test/unit/admin-contributions.test.ts', content: adminTest(spec) });
|
|
57
|
+
}
|
|
58
|
+
return files;
|
|
59
|
+
}
|
|
60
|
+
/** `QuickOrderItem` — the module's own aggregate, when it owns one. */
|
|
61
|
+
export function entityClassOf(spec) {
|
|
62
|
+
return `${pascalOf(spec.id)}Item`;
|
|
63
|
+
}
|
|
64
|
+
/** `quick_order_items` — the table that entity maps, owned by this module alone. */
|
|
65
|
+
export function tableNameOf(spec) {
|
|
66
|
+
return `${segmentOf(spec.id)}_items`;
|
|
67
|
+
}
|
|
68
|
+
export function entityFileOf(spec) {
|
|
69
|
+
return `${slugOf(spec.id)}-item.entity.ts`;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* `<stamp>_<segment>_init.ts` — the filename
|
|
73
|
+
* `specs/065-manifest-aware-migrations/contracts/naming-convention.md` §1
|
|
74
|
+
* recognises, with the tail beginning at the owning module's segment so the
|
|
75
|
+
* class name derived from it is scoped (feature 081, `unscoped-name`).
|
|
76
|
+
*/
|
|
77
|
+
export function migrationFileOf(spec) {
|
|
78
|
+
return `${spec.migrationStamp}_${segmentOf(spec.id)}_init.ts`;
|
|
79
|
+
}
|
|
80
|
+
/** `Migration<STAMP><PascalCaseTail>` — §2's mechanical derivation from the filename. */
|
|
81
|
+
export function migrationClassOf(spec) {
|
|
82
|
+
const tail = `${segmentOf(spec.id)}_init`
|
|
83
|
+
.split('_')
|
|
84
|
+
.filter((part) => part.length > 0)
|
|
85
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
86
|
+
.join('');
|
|
87
|
+
return `Migration${spec.migrationStamp}${tail}`;
|
|
88
|
+
}
|
|
89
|
+
/** `<Pascal>ReadPort` — the interface `./ports` publishes when the module owns one. */
|
|
90
|
+
export function portInterfaceOf(spec) {
|
|
91
|
+
return `${pascalOf(spec.id)}ReadPort`;
|
|
92
|
+
}
|
|
93
|
+
/** `<camel>ReadPort` — the container name that interface is registered under. */
|
|
94
|
+
export function portNameOf(spec) {
|
|
95
|
+
return `${camelOf(spec.id)}ReadPort`;
|
|
96
|
+
}
|
|
97
|
+
// --- the two build configurations, a constant of the layout ----------------
|
|
98
|
+
function typecheckConfig(spec, depth) {
|
|
99
|
+
const hasAdmin = spec.layers.admin !== null;
|
|
100
|
+
const excluded = hasAdmin
|
|
101
|
+
? ['src/admin/**/*', 'src/**/*.test.ts', 'src/**/*.spec.ts']
|
|
102
|
+
: ['src/**/*.test.ts', 'src/**/*.spec.ts'];
|
|
103
|
+
const adminExclusionComment = hasAdmin
|
|
104
|
+
? `
|
|
105
|
+
//
|
|
106
|
+
// \`src/admin/\` is compiled by \`tsconfig.ui.json\` and by nothing else. It is
|
|
107
|
+
// excluded here rather than merged in because this configuration — and
|
|
108
|
+
// \`tsconfig.build.json\`, which extends it — is the one that must **not**
|
|
109
|
+
// carry \`jsx\` or the \`DOM\` lib: a service file that referenced \`document\`
|
|
110
|
+
// would otherwise compile clean, in the layer that runs in Node.`
|
|
111
|
+
: '';
|
|
112
|
+
return `{
|
|
113
|
+
// Type-check configuration, and the two-config split every package under
|
|
114
|
+
// \`packages/\` uses. \`tsconfig.base.json\`'s \`paths\` block is active here, so
|
|
115
|
+
// \`@endora-commerce/contracts\` resolves at its *source* and this package
|
|
116
|
+
// type-checks against the branch it is on (issue #255). \`noEmit\` is set for
|
|
117
|
+
// that reason: with \`paths\` active an emit would land beside another
|
|
118
|
+
// package's source. The emitting configuration is \`tsconfig.build.json\`.
|
|
119
|
+
//
|
|
120
|
+
// \`@endora-commerce/platform\` is deliberately **not** in that \`paths\` block
|
|
121
|
+
// and is resolved here through its own \`exports\` map, at its built \`.d.ts\`.
|
|
122
|
+
// That is the same resolution the running application gets, which is what
|
|
123
|
+
// keeps one copy of \`HttpError\`, \`SalesChannel\` and \`effectiveState\` in the
|
|
124
|
+
// process. This package is refused a \`paths\` entry for its own sources on the
|
|
125
|
+
// same grounds, one step sharper: \`paths\` is honoured by \`tsx\` and not by
|
|
126
|
+
// \`node\`, so an entry would make a \`tsx\` process load this package's sources
|
|
127
|
+
// rather than its \`dist\`, which is the split D-164 measured killing a
|
|
128
|
+
// decorated entity.
|
|
129
|
+
"extends": "${rootPrefixFor(depth)}/tsconfig.base.json",
|
|
130
|
+
"compilerOptions": {
|
|
131
|
+
"outDir": "./dist",
|
|
132
|
+
"noEmit": true,
|
|
133
|
+
"types": ["node"]
|
|
134
|
+
},
|
|
135
|
+
"include": ["src/**/*"],
|
|
136
|
+
// A co-located test must not be compiled into \`dist\`: it imports \`vitest\`, a
|
|
137
|
+
// devDependency, which is an unresolvable specifier in every consumer's
|
|
138
|
+
// install. That is this exclusion's whole job, and it is an *emit* concern —
|
|
139
|
+
// \`tsconfig.build.json\` extends this file, which is how it gets there. It
|
|
140
|
+
// does not stop those tests running: \`vitest\` reads its own \`include\` and
|
|
141
|
+
// transpiles what it collects.${adminExclusionComment}
|
|
142
|
+
"exclude": [${excluded.map((pattern) => `"${pattern}"`).join(', ')}]
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
function buildConfig() {
|
|
147
|
+
return `{
|
|
148
|
+
// Emit configuration — the only configuration in this package allowed to
|
|
149
|
+
// write files, and the declaration the platform's composer reads to learn
|
|
150
|
+
// where this package's sources end up.
|
|
151
|
+
//
|
|
152
|
+
// \`paths\` is cleared, so \`@endora-commerce/contracts\` resolves through its
|
|
153
|
+
// own \`exports\` map to its built \`.d.ts\`. \`rootDir\` with an active \`paths\`
|
|
154
|
+
// block is the TS6059 trap: \`tsc\` reports the sibling's source as outside
|
|
155
|
+
// \`rootDir\`, exits 2, and emits that sibling's \`.js\`/\`.d.ts\` beside its
|
|
156
|
+
// source anyway.
|
|
157
|
+
//
|
|
158
|
+
// The artefact format is the package format, not a per-package judgement
|
|
159
|
+
// (D-164): the manifest generator derives every \`exports\` target from this
|
|
160
|
+
// file's \`rootDir\`/\`outDir\`, so a source-shipping module package is not
|
|
161
|
+
// expressible, and a package that shipped source would be the one whose
|
|
162
|
+
// specifiers resolved differently under \`tsx\` than under \`node\`.
|
|
163
|
+
"extends": "./tsconfig.json",
|
|
164
|
+
"compilerOptions": {
|
|
165
|
+
"paths": {},
|
|
166
|
+
"rootDir": "./src",
|
|
167
|
+
"noEmit": false,
|
|
168
|
+
// A compile that failed must ship nothing. Without this a diagnostic is not
|
|
169
|
+
// a stop — the artefacts are written before the exit code is.
|
|
170
|
+
"noEmitOnError": true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
`;
|
|
174
|
+
}
|
|
175
|
+
function uiConfig() {
|
|
176
|
+
return `{
|
|
177
|
+
// Emit configuration for this package's **admin** layer.
|
|
178
|
+
//
|
|
179
|
+
// A second configuration, and not a widening of the first one, because the
|
|
180
|
+
// two layers run in two runtimes: \`jsx: "react-jsx"\` and the \`DOM\` lib are
|
|
181
|
+
// what a React screen needs and what a service file must not have. One config
|
|
182
|
+
// for both lets a file under \`src/backend/\` reference \`document\` and compile
|
|
183
|
+
// clean, in the layer that runs in Node — the compiler prevents that class of
|
|
184
|
+
// defect for free, and "we will be careful" is not a mechanism.
|
|
185
|
+
//
|
|
186
|
+
// \`paths\` is cleared for \`tsconfig.build.json\`'s reason: \`rootDir\` with an
|
|
187
|
+
// active \`paths\` block is TS6059, which exits 2 and emits the sibling
|
|
188
|
+
// package's output beside that sibling's source. So
|
|
189
|
+
// \`@endora-commerce/admin-kit/*\` resolves through its own \`exports\` map at
|
|
190
|
+
// its built \`.d.ts\`, which is the resolution the admin's Vite build gets too.
|
|
191
|
+
//
|
|
192
|
+
// \`rootDir\` and \`outDir\` are \`tsconfig.build.json\`'s, deliberately: the
|
|
193
|
+
// package publishes one \`dist\`, and the manifest generator derives the
|
|
194
|
+
// \`"./admin"\` target from that single emit layout. Two layouts would be two
|
|
195
|
+
// answers to "where does src/admin/index.ts end up".
|
|
196
|
+
"extends": "./tsconfig.json",
|
|
197
|
+
"compilerOptions": {
|
|
198
|
+
"paths": {},
|
|
199
|
+
"rootDir": "./src",
|
|
200
|
+
"noEmit": false,
|
|
201
|
+
"noEmitOnError": true,
|
|
202
|
+
"jsx": "react-jsx",
|
|
203
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
204
|
+
// No ambient \`node\` types: this layer is a browser bundle, and a screen
|
|
205
|
+
// that reaches for \`process.env\` should not compile.
|
|
206
|
+
"types": []
|
|
207
|
+
},
|
|
208
|
+
"include": ["src/admin/**/*"],
|
|
209
|
+
// The inherited \`exclude\` names \`src/admin/**/*\` — that is what keeps the
|
|
210
|
+
// backend program off this layer — so it has to be **replaced** here rather
|
|
211
|
+
// than inherited, or \`tsc\` reports TS18003 over the one directory this
|
|
212
|
+
// program exists to compile. The test patterns are kept for the sibling
|
|
213
|
+
// config's reason: a co-located test compiled into \`dist\` imports \`vitest\`,
|
|
214
|
+
// an unresolvable specifier in every consumer's install.
|
|
215
|
+
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"]
|
|
216
|
+
}
|
|
217
|
+
`;
|
|
218
|
+
}
|
|
219
|
+
function vitestConfig(spec, depth) {
|
|
220
|
+
return `// This module package's own test run.
|
|
221
|
+
//
|
|
222
|
+
// Two things here are load-bearing rather than boilerplate:
|
|
223
|
+
//
|
|
224
|
+
// * **\`mergeConfig(baseConfig, …)\`** — the base is the one file every
|
|
225
|
+
// workspace's vitest configuration merges, and it is where issue #255's
|
|
226
|
+
// foreign-workspace-link refusal lives. A configuration that skipped it
|
|
227
|
+
// would let this package's run execute *another checkout's* sources while
|
|
228
|
+
// reporting on this branch.
|
|
229
|
+
// * **no \`--passWithNoTests\`, anywhere** — neither here nor in the generated
|
|
230
|
+
// \`test\` script. A run that collects zero files from a package that ships
|
|
231
|
+
// them must exit non-zero.
|
|
232
|
+
|
|
233
|
+
import { defineConfig, mergeConfig } from 'vitest/config';
|
|
234
|
+
import baseConfig from '${rootPrefixFor(depth)}/vitest.config.base.js';
|
|
235
|
+
|
|
236
|
+
export default mergeConfig(
|
|
237
|
+
baseConfig,
|
|
238
|
+
defineConfig({
|
|
239
|
+
test: {
|
|
240
|
+
name: ${quote(`mod-${slugOf(spec.id)}`)},
|
|
241
|
+
environment: 'node',
|
|
242
|
+
// Both roots the layout contract allows: co-located beside the source,
|
|
243
|
+
// and the package-owned \`test/unit/\` tree.
|
|
244
|
+
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
|
245
|
+
},
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
`;
|
|
249
|
+
}
|
|
250
|
+
// --- src/manifest.ts -------------------------------------------------------
|
|
251
|
+
function moduleManifest(spec) {
|
|
252
|
+
const manifest = manifestObjectFor(spec);
|
|
253
|
+
const lines = [];
|
|
254
|
+
lines.push(`import { defineModuleManifest } from '@endora-commerce/contracts';`);
|
|
255
|
+
lines.push('');
|
|
256
|
+
lines.push('/**');
|
|
257
|
+
lines.push(` * ${spec.name} — module manifest.`);
|
|
258
|
+
lines.push(' *');
|
|
259
|
+
lines.push(' * The root export of a module package, and the module\'s identity of record: the');
|
|
260
|
+
lines.push(' * lifecycle registry, the settings store, every permission code and every');
|
|
261
|
+
lines.push(' * migration owner key on the `id` below, which must equal this directory\'s name');
|
|
262
|
+
lines.push(' * and the `endora.id` in `package.json`.');
|
|
263
|
+
lines.push(' *');
|
|
264
|
+
lines.push(' * `version` is **not** wired to `package.json`\'s. They are different facts: the');
|
|
265
|
+
lines.push(' * package version is `0.0.0` for every package in this family, while this one is');
|
|
266
|
+
lines.push(' * what the lifecycle registry stores and what the admin\'s `pending-upgrade` flag');
|
|
267
|
+
lines.push(' * compares. Importing one into the other puts `0.0.0` in the registry and');
|
|
268
|
+
lines.push(' * silently disables drift detection.');
|
|
269
|
+
lines.push(' */');
|
|
270
|
+
lines.push('export const manifest = defineModuleManifest({');
|
|
271
|
+
lines.push(` id: ${quote(spec.id)},`);
|
|
272
|
+
lines.push(` name: ${quote(spec.name)},`);
|
|
273
|
+
lines.push(` description: ${quote(spec.description)},`);
|
|
274
|
+
lines.push(` version: '1.0.0',`);
|
|
275
|
+
if (spec.dependencies.length === 0) {
|
|
276
|
+
lines.push(' // Every module whose port this one resolves belongs here: that is what makes');
|
|
277
|
+
lines.push(' // the edge real to the lifecycle, to the migration order and to an operator');
|
|
278
|
+
lines.push(' // switching the owner off.');
|
|
279
|
+
lines.push(' dependencies: [],');
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
lines.push(' // A module whose port this one resolves belongs here. The declaration is');
|
|
283
|
+
lines.push(' // what makes the edge real to the lifecycle, to the migration order and to');
|
|
284
|
+
lines.push(' // an operator switching the owner off — `check:port-dependencies` refuses a');
|
|
285
|
+
lines.push(' // `lazyPort` resolution the manifest does not declare.');
|
|
286
|
+
lines.push(' dependencies: [');
|
|
287
|
+
for (const dependency of spec.dependencies)
|
|
288
|
+
lines.push(` ${quote(dependency)},`);
|
|
289
|
+
lines.push(' ],');
|
|
290
|
+
}
|
|
291
|
+
if (spec.activation.kind === 'setting') {
|
|
292
|
+
lines.push(' // Constitution XVII — the operator activation axis. A module that is off');
|
|
293
|
+
lines.push(' // behaves as if never installed; off is non-destructive and reversible.');
|
|
294
|
+
lines.push(` activation: { settingCode: ${quote(spec.activation.settingCode)}, default: true },`);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
lines.push(' // Constitution XVII — this module declares that the platform cannot run');
|
|
298
|
+
lines.push(' // without it, so the orchestrator refuses to disable *or* uninstall it, and');
|
|
299
|
+
lines.push(' // a composition that lacks it is refused before any module registers.');
|
|
300
|
+
lines.push(' activation: {');
|
|
301
|
+
lines.push(' nonDeactivatable: true,');
|
|
302
|
+
lines.push(` reason: ${quote(spec.activation.reason)},`);
|
|
303
|
+
lines.push(' },');
|
|
304
|
+
}
|
|
305
|
+
lines.push(` i18n: { bundlesDir: 'i18n' },`);
|
|
306
|
+
if (manifest.permissions !== undefined) {
|
|
307
|
+
lines.push(' // Every code a `requireAdmin(...)` literal in this module enforces, so it is');
|
|
308
|
+
lines.push(' // grantable on /admin-roles. The label an operator reads is the');
|
|
309
|
+
lines.push(' // `adminRoles.permission.<code>` key in this package\'s own bundles.');
|
|
310
|
+
lines.push(' permissions: [');
|
|
311
|
+
for (const permission of manifest.permissions) {
|
|
312
|
+
lines.push(' {');
|
|
313
|
+
lines.push(` code: ${quote(permission.code)},`);
|
|
314
|
+
lines.push(` module: ${quote(permission.module ?? spec.id)},`);
|
|
315
|
+
lines.push(` label: ${quote(permission.label)},`);
|
|
316
|
+
lines.push(' },');
|
|
317
|
+
}
|
|
318
|
+
lines.push(' ],');
|
|
319
|
+
}
|
|
320
|
+
if (manifest.actions !== undefined) {
|
|
321
|
+
lines.push(' // Principle XVI — a module with an admin surface is discoverable under');
|
|
322
|
+
lines.push(' // CTRL+K. `requiredPermission` is the code enforced on this action\'s own');
|
|
323
|
+
lines.push(' // target route, so the palette never advertises a 403.');
|
|
324
|
+
lines.push(' actions: [');
|
|
325
|
+
for (const action of manifest.actions) {
|
|
326
|
+
lines.push(' {');
|
|
327
|
+
lines.push(` id: ${quote(action.id)},`);
|
|
328
|
+
lines.push(` labelKey: ${quote(action.labelKey)},`);
|
|
329
|
+
lines.push(` descriptionKey: ${quote(action.descriptionKey ?? '')},`);
|
|
330
|
+
lines.push(` icon: ${quote(action.icon)},`);
|
|
331
|
+
lines.push(` targetRoute: ${quote(action.targetRoute)},`);
|
|
332
|
+
lines.push(` requiredPermission: ${quote(action.requiredPermission ?? '')},`);
|
|
333
|
+
lines.push(` keywords: [${action.keywords.map(quote).join(', ')}],`);
|
|
334
|
+
lines.push(` weight: ${String(action.weight)},`);
|
|
335
|
+
lines.push(' },');
|
|
336
|
+
}
|
|
337
|
+
lines.push(' ],');
|
|
338
|
+
}
|
|
339
|
+
lines.push('});');
|
|
340
|
+
lines.push('');
|
|
341
|
+
return lines.join('\n');
|
|
342
|
+
}
|
|
343
|
+
// --- src/backend/index.ts --------------------------------------------------
|
|
344
|
+
function backendIndex(spec) {
|
|
345
|
+
const pascal = pascalOf(spec.id);
|
|
346
|
+
const camel = camelOf(spec.id);
|
|
347
|
+
const cradleName = `${pascal}Cradle`;
|
|
348
|
+
const serviceName = `${pascal}Service`;
|
|
349
|
+
const hasRoutes = spec.permissions.length > 0;
|
|
350
|
+
const registersDeferred = hasRoutes || spec.layers.worker;
|
|
351
|
+
const lines = [];
|
|
352
|
+
lines.push(`import type { ModuleContext } from '@endora-commerce/platform/kernel';`);
|
|
353
|
+
if (hasRoutes) {
|
|
354
|
+
lines.push(`import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';`);
|
|
355
|
+
}
|
|
356
|
+
if (spec.layers.entities) {
|
|
357
|
+
lines.push(`import type { EntityManager } from '@mikro-orm/postgresql';`);
|
|
358
|
+
}
|
|
359
|
+
if (spec.layers.worker) {
|
|
360
|
+
lines.push(`import type { Redis } from 'ioredis';`);
|
|
361
|
+
}
|
|
362
|
+
if (spec.layers.ports) {
|
|
363
|
+
lines.push(`import type { ${portInterfaceOf(spec)} } from '../ports/index.js';`);
|
|
364
|
+
}
|
|
365
|
+
lines.push(`import { ${serviceName} } from './services/${slugOf(spec.id)}.service.js';`);
|
|
366
|
+
if (hasRoutes) {
|
|
367
|
+
lines.push(`import { register${pascal}AdminRoutes } from './routes.admin.js';`);
|
|
368
|
+
}
|
|
369
|
+
if (spec.layers.entities) {
|
|
370
|
+
lines.push(`import { ${entityClassOf(spec)} } from './entities/${slugOf(spec.id)}-item.entity.js';`);
|
|
371
|
+
}
|
|
372
|
+
if (spec.layers.worker) {
|
|
373
|
+
lines.push(`import { create${pascal}Worker } from './workers/${slugOf(spec.id)}.worker.js';`);
|
|
374
|
+
}
|
|
375
|
+
lines.push('');
|
|
376
|
+
lines.push('/**');
|
|
377
|
+
lines.push(` * ${spec.name} — composition (\`registerModule\`).`);
|
|
378
|
+
lines.push(' *');
|
|
379
|
+
lines.push(' * The kernel container composes this module; there is no composition root that');
|
|
380
|
+
lines.push(' * constructs its services. Everything it owns is registered here, everything it');
|
|
381
|
+
lines.push(' * borrows is resolved through a seam, and every seam applies the module gate, so');
|
|
382
|
+
lines.push(' * an operator switching this module off stops its routes, its queue consumers and');
|
|
383
|
+
lines.push(' * its subscriptions without a check written per call site.');
|
|
384
|
+
lines.push(' */');
|
|
385
|
+
lines.push('');
|
|
386
|
+
lines.push('/** What this module resolves from the container, and the names it owns. */');
|
|
387
|
+
lines.push(`export interface ${cradleName} {`);
|
|
388
|
+
if (spec.layers.entities)
|
|
389
|
+
lines.push(' readonly emFactory: () => EntityManager;');
|
|
390
|
+
if (hasRoutes)
|
|
391
|
+
lines.push(' readonly requireAdmin: RequireAdminFactory;');
|
|
392
|
+
if (spec.layers.worker) {
|
|
393
|
+
lines.push(' /** The host\'s queue connection, absent in a composition that runs no queue. */');
|
|
394
|
+
lines.push(' readonly moduleQueueRedis: Redis | undefined;');
|
|
395
|
+
}
|
|
396
|
+
lines.push(` readonly ${camel}Service: ${serviceName};`);
|
|
397
|
+
lines.push('}');
|
|
398
|
+
lines.push('');
|
|
399
|
+
if (spec.layers.subscriber) {
|
|
400
|
+
lines.push('/** This module\'s own settings namespace, watched by the subscriber below. */');
|
|
401
|
+
lines.push(`const SETTINGS_NAMESPACE = ${quote(`${spec.id}.`)};`);
|
|
402
|
+
lines.push('');
|
|
403
|
+
lines.push('/** The changed setting\'s code, read off the event payload without trusting it. */');
|
|
404
|
+
lines.push('function settingCodeOf(payload: unknown): string | null {');
|
|
405
|
+
lines.push(' const code = (payload as { settingCode?: unknown } | null)?.settingCode;');
|
|
406
|
+
lines.push(' return typeof code === \'string\' ? code : null;');
|
|
407
|
+
lines.push('}');
|
|
408
|
+
lines.push('');
|
|
409
|
+
}
|
|
410
|
+
lines.push(`export function registerModule(ctx: ModuleContext): void {`);
|
|
411
|
+
lines.push(' ctx.di.register({');
|
|
412
|
+
if (spec.layers.entities) {
|
|
413
|
+
lines.push(` ${camel}Service: ctx`);
|
|
414
|
+
lines.push(` .asFunction(({ emFactory }: ${cradleName}) => new ${serviceName}(emFactory))`);
|
|
415
|
+
lines.push(' .singleton(),');
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
lines.push(` ${camel}Service: ctx.asFunction(() => new ${serviceName}()).singleton(),`);
|
|
419
|
+
}
|
|
420
|
+
lines.push(' });');
|
|
421
|
+
if (spec.layers.ports) {
|
|
422
|
+
lines.push('');
|
|
423
|
+
lines.push(' // The one name another module may resolve. `providePort` wraps the');
|
|
424
|
+
lines.push(' // registration in a transient gate on this module\'s effective state, so a');
|
|
425
|
+
lines.push(' // consumer resolving it while this module is off gets the 503 MODULE_DISABLED');
|
|
426
|
+
lines.push(' // envelope at the call site instead of a half-executed operation.');
|
|
427
|
+
lines.push(' //');
|
|
428
|
+
lines.push(' // A consumer reaches it with');
|
|
429
|
+
lines.push(' // `lazyPort<' + portInterfaceOf(spec) + '>(ctx, ' + quote(portNameOf(spec)) + ')`');
|
|
430
|
+
lines.push(' // and declares this module in its own manifest `dependencies`.');
|
|
431
|
+
lines.push(` ctx.di.providePort<${portInterfaceOf(spec)}>(`);
|
|
432
|
+
lines.push(` ${quote(portNameOf(spec))},`);
|
|
433
|
+
lines.push(' ctx');
|
|
434
|
+
lines.push(` .asFunction(({ ${camel}Service }: ${cradleName}) => ({`);
|
|
435
|
+
if (spec.layers.entities) {
|
|
436
|
+
lines.push(` list: () => ${camel}Service.list(),`);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
lines.push(` status: () => ${camel}Service.status(),`);
|
|
440
|
+
}
|
|
441
|
+
lines.push(' }))');
|
|
442
|
+
lines.push(' .singleton(),');
|
|
443
|
+
lines.push(' );');
|
|
444
|
+
}
|
|
445
|
+
if (registersDeferred) {
|
|
446
|
+
lines.push('');
|
|
447
|
+
lines.push(' // `ctx.routes` is the gated registration seam: everything registered inside it');
|
|
448
|
+
lines.push(' // stops answering when the module is off, including routes added later.');
|
|
449
|
+
lines.push(' // Never call `defineModuleRoutes` by hand.');
|
|
450
|
+
lines.push(' ctx.routes(async (app) => {');
|
|
451
|
+
const destructured = [
|
|
452
|
+
...(hasRoutes ? ['requireAdmin'] : []),
|
|
453
|
+
`${camel}Service`,
|
|
454
|
+
...(spec.layers.worker ? ['moduleQueueRedis'] : []),
|
|
455
|
+
];
|
|
456
|
+
if (destructured.length > 2) {
|
|
457
|
+
lines.push(' const {');
|
|
458
|
+
for (const name of destructured)
|
|
459
|
+
lines.push(` ${name},`);
|
|
460
|
+
lines.push(` } = ctx.cradle<${cradleName}>();`);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
lines.push(` const { ${destructured.join(', ')} } = ctx.cradle<${cradleName}>();`);
|
|
464
|
+
}
|
|
465
|
+
if (hasRoutes) {
|
|
466
|
+
lines.push(` await register${pascal}AdminRoutes(app, {`);
|
|
467
|
+
lines.push(` service: ${camel}Service,`);
|
|
468
|
+
lines.push(' requireAdmin,');
|
|
469
|
+
lines.push(' });');
|
|
470
|
+
}
|
|
471
|
+
if (spec.layers.worker) {
|
|
472
|
+
lines.push('');
|
|
473
|
+
lines.push(' // Principle X — a queue consumer, registered through `ctx.worker` so the');
|
|
474
|
+
lines.push(' // module gate can stop it. A bare `new Worker` kept in a local is a');
|
|
475
|
+
lines.push(' // consumer nothing in the platform can pause.');
|
|
476
|
+
lines.push(` if (process.env['BACKEND_ROLE'] !== 'api' && moduleQueueRedis !== undefined) {`);
|
|
477
|
+
lines.push(' ctx.worker(');
|
|
478
|
+
lines.push(` create${pascal}Worker(moduleQueueRedis, async () => ({`);
|
|
479
|
+
if (spec.layers.entities) {
|
|
480
|
+
lines.push(` scanned: (await ${camel}Service.list()).length,`);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
lines.push(` scanned: ${camel}Service.status().module.length,`);
|
|
484
|
+
}
|
|
485
|
+
lines.push(' })),');
|
|
486
|
+
lines.push(' { logger: app.log },');
|
|
487
|
+
lines.push(' );');
|
|
488
|
+
lines.push(' }');
|
|
489
|
+
}
|
|
490
|
+
lines.push(' });');
|
|
491
|
+
}
|
|
492
|
+
if (spec.layers.subscriber) {
|
|
493
|
+
lines.push('');
|
|
494
|
+
lines.push(' // Constitution XVII item 2 — an EventBus subscription registered through');
|
|
495
|
+
lines.push(' // `ctx.subscribe`, from this file. A bare `eventBus.on` in a service body is a');
|
|
496
|
+
lines.push(' // subscription the module gate cannot reach.');
|
|
497
|
+
lines.push(` ctx.subscribe('settings.value_changed', (payload) => {`);
|
|
498
|
+
lines.push(' const code = settingCodeOf(payload);');
|
|
499
|
+
lines.push(' if (code === null || !code.startsWith(SETTINGS_NAMESPACE)) return;');
|
|
500
|
+
lines.push(' ctx.log.info(');
|
|
501
|
+
lines.push(' { settingCode: code },');
|
|
502
|
+
lines.push(" 'a setting this module owns changed; anything derived from it is stale here',");
|
|
503
|
+
lines.push(' );');
|
|
504
|
+
lines.push(' });');
|
|
505
|
+
}
|
|
506
|
+
lines.push('}');
|
|
507
|
+
if (spec.layers.entities) {
|
|
508
|
+
lines.push('');
|
|
509
|
+
lines.push('/**');
|
|
510
|
+
lines.push(' * The module\'s persisted entity classes, on the `./backend` subpath, as one');
|
|
511
|
+
lines.push(' * array and **no named class export** (D-168).');
|
|
512
|
+
lines.push(' *');
|
|
513
|
+
lines.push(' * This is the shape the platform reads when the package is *installed*: the');
|
|
514
|
+
lines.push(' * boot-time loader and the static declaration reader both take');
|
|
515
|
+
lines.push(' * `exported[\'entities\']`, and a missing array is answered with `[]` — zero');
|
|
516
|
+
lines.push(' * entities registered, no error anywhere.');
|
|
517
|
+
lines.push(' */');
|
|
518
|
+
lines.push(`export const entities = [${entityClassOf(spec)}];`);
|
|
519
|
+
}
|
|
520
|
+
lines.push('');
|
|
521
|
+
return lines.join('\n');
|
|
522
|
+
}
|
|
523
|
+
// --- src/backend/services/<slug>.service.ts --------------------------------
|
|
524
|
+
function service(spec) {
|
|
525
|
+
const pascal = pascalOf(spec.id);
|
|
526
|
+
const lines = [];
|
|
527
|
+
if (spec.layers.entities) {
|
|
528
|
+
lines.push(`import type { EntityManager } from '@mikro-orm/postgresql';`);
|
|
529
|
+
if (spec.layers.ports) {
|
|
530
|
+
lines.push(`import type { ${pascal}Record } from '../../ports/index.js';`);
|
|
531
|
+
}
|
|
532
|
+
lines.push(`import { ${entityClassOf(spec)} } from '../entities/${slugOf(spec.id)}-item.entity.js';`);
|
|
533
|
+
lines.push('');
|
|
534
|
+
if (!spec.layers.ports) {
|
|
535
|
+
lines.push('/** One row as this module hands it out: never the managed entity itself. */');
|
|
536
|
+
lines.push(`export interface ${pascal}Record {`);
|
|
537
|
+
lines.push(' readonly id: string;');
|
|
538
|
+
lines.push(' readonly label: string;');
|
|
539
|
+
lines.push(' readonly createdAt: string;');
|
|
540
|
+
lines.push('}');
|
|
541
|
+
lines.push('');
|
|
542
|
+
}
|
|
543
|
+
lines.push('/**');
|
|
544
|
+
lines.push(` * ${spec.name} — the module's own service.`);
|
|
545
|
+
lines.push(' *');
|
|
546
|
+
lines.push(' * It takes an `EntityManager` **factory** rather than an instance: the request');
|
|
547
|
+
lines.push(' * scope forks one per request, so a captured instance is one transaction serving');
|
|
548
|
+
lines.push(' * every caller.');
|
|
549
|
+
lines.push(' *');
|
|
550
|
+
lines.push(' * This one only reads. A write belongs on the Command Bus (Constitution XIII):');
|
|
551
|
+
lines.push(' * `commandBus.run(...)` is what keeps auditing and undo uniform, and');
|
|
552
|
+
lines.push(' * `check:command-coverage` reports a `persist`/`flush`/`nativeUpdate` that runs');
|
|
553
|
+
lines.push(' * outside one.');
|
|
554
|
+
lines.push(' */');
|
|
555
|
+
lines.push(`export class ${pascal}Service {`);
|
|
556
|
+
lines.push(' constructor(private readonly emFactory: () => EntityManager) {}');
|
|
557
|
+
lines.push('');
|
|
558
|
+
lines.push(` async list(): Promise<readonly ${pascal}Record[]> {`);
|
|
559
|
+
lines.push(` const rows = await this.emFactory().find(`);
|
|
560
|
+
lines.push(` ${entityClassOf(spec)},`);
|
|
561
|
+
lines.push(' {},');
|
|
562
|
+
lines.push(` { orderBy: { createdAt: 'desc' }, limit: 100 },`);
|
|
563
|
+
lines.push(' );');
|
|
564
|
+
lines.push(' return rows.map((row) => ({');
|
|
565
|
+
lines.push(' id: row.id,');
|
|
566
|
+
lines.push(' label: row.label,');
|
|
567
|
+
lines.push(' createdAt: row.createdAt.toISOString(),');
|
|
568
|
+
lines.push(' }));');
|
|
569
|
+
lines.push(' }');
|
|
570
|
+
lines.push('}');
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
if (spec.layers.ports) {
|
|
574
|
+
lines.push(`import type { ${pascal}Status } from '../../ports/index.js';`);
|
|
575
|
+
lines.push('');
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
lines.push('/** What this module reports about itself. */');
|
|
579
|
+
lines.push(`export interface ${pascal}Status {`);
|
|
580
|
+
lines.push(' readonly module: string;');
|
|
581
|
+
lines.push(' readonly name: string;');
|
|
582
|
+
lines.push('}');
|
|
583
|
+
lines.push('');
|
|
584
|
+
}
|
|
585
|
+
lines.push('/**');
|
|
586
|
+
lines.push(` * ${spec.name} — the module's own service.`);
|
|
587
|
+
lines.push(' *');
|
|
588
|
+
lines.push(' * This module owns no table, so the service holds no `EntityManager`. A write');
|
|
589
|
+
lines.push(' * belongs on the Command Bus (Constitution XIII): `commandBus.run(...)` is what');
|
|
590
|
+
lines.push(' * keeps auditing and undo uniform.');
|
|
591
|
+
lines.push(' */');
|
|
592
|
+
lines.push(`export class ${pascal}Service {`);
|
|
593
|
+
lines.push(` status(): ${pascal}Status {`);
|
|
594
|
+
lines.push(' return {');
|
|
595
|
+
lines.push(` module: ${quote(spec.id)},`);
|
|
596
|
+
lines.push(` name: ${quote(spec.name)},`);
|
|
597
|
+
lines.push(' };');
|
|
598
|
+
lines.push(' }');
|
|
599
|
+
lines.push('}');
|
|
600
|
+
}
|
|
601
|
+
lines.push('');
|
|
602
|
+
return lines.join('\n');
|
|
603
|
+
}
|
|
604
|
+
// --- src/backend/routes.admin.ts -------------------------------------------
|
|
605
|
+
function adminRoutes(spec) {
|
|
606
|
+
const pascal = pascalOf(spec.id);
|
|
607
|
+
const gate = gatingPermissionOf(spec);
|
|
608
|
+
if (gate === null)
|
|
609
|
+
throw new Error('adminRoutes emitted without a permission to gate on');
|
|
610
|
+
const lines = [];
|
|
611
|
+
lines.push(`import type { FastifyInstance } from 'fastify';`);
|
|
612
|
+
lines.push(`import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';`);
|
|
613
|
+
lines.push(`import type { ${pascal}Service } from './services/${slugOf(spec.id)}.service.js';`);
|
|
614
|
+
lines.push('');
|
|
615
|
+
lines.push('/**');
|
|
616
|
+
lines.push(` * ${spec.name} — admin routes.`);
|
|
617
|
+
lines.push(' *');
|
|
618
|
+
lines.push(' * The permission code is a **literal** in the `requireAdmin(...)` call, because');
|
|
619
|
+
lines.push(' * that is what the permission inventory and `check:action-route-permissions` read;');
|
|
620
|
+
lines.push(' * a computed code is an argument they refuse rather than skip. It matches the');
|
|
621
|
+
lines.push(' * `permissions` entry in `src/manifest.ts` exactly, which is what makes it');
|
|
622
|
+
lines.push(' * grantable on /admin-roles.');
|
|
623
|
+
lines.push(' */');
|
|
624
|
+
lines.push(`export interface ${pascal}AdminRoutesDeps {`);
|
|
625
|
+
lines.push(` readonly service: ${pascal}Service;`);
|
|
626
|
+
lines.push(' readonly requireAdmin: RequireAdminFactory;');
|
|
627
|
+
lines.push('}');
|
|
628
|
+
lines.push('');
|
|
629
|
+
lines.push(`export async function register${pascal}AdminRoutes(`);
|
|
630
|
+
lines.push(' app: FastifyInstance,');
|
|
631
|
+
lines.push(` deps: ${pascal}AdminRoutesDeps,`);
|
|
632
|
+
lines.push('): Promise<void> {');
|
|
633
|
+
lines.push(' const { service, requireAdmin } = deps;');
|
|
634
|
+
for (const route of adminRoutesOf(spec)) {
|
|
635
|
+
lines.push('');
|
|
636
|
+
lines.push(' app.get(');
|
|
637
|
+
lines.push(` ${quote(adminApiPathFor(route))},`);
|
|
638
|
+
lines.push(` { preHandler: requireAdmin(${quote(gate.code)}) },`);
|
|
639
|
+
if (spec.layers.entities) {
|
|
640
|
+
lines.push(' async () => ({ data: await service.list() }),');
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
lines.push(' async () => ({ data: service.status() }),');
|
|
644
|
+
}
|
|
645
|
+
lines.push(' );');
|
|
646
|
+
}
|
|
647
|
+
lines.push('}');
|
|
648
|
+
lines.push('');
|
|
649
|
+
return lines.join('\n');
|
|
650
|
+
}
|
|
651
|
+
// --- src/backend/entities/<slug>-item.entity.ts ----------------------------
|
|
652
|
+
/** The tenant column each scope adds to the entity and to the table. */
|
|
653
|
+
function tenantColumn(spec) {
|
|
654
|
+
if (spec.tenantScope === 'org-scoped') {
|
|
655
|
+
return { property: 'organizationId', column: 'organization_id' };
|
|
656
|
+
}
|
|
657
|
+
if (spec.tenantScope === 'customer-scoped') {
|
|
658
|
+
return { property: 'customerAccountId', column: 'customer_account_id' };
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
function entity(spec) {
|
|
663
|
+
const decorator = TENANT_SCOPE_DECORATORS[spec.tenantScope];
|
|
664
|
+
const tenant = tenantColumn(spec);
|
|
665
|
+
const lines = [];
|
|
666
|
+
lines.push(`import { Entity, Index, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';`);
|
|
667
|
+
lines.push(`import { ${decorator} } from '@endora-commerce/platform/tenancy';`);
|
|
668
|
+
lines.push(`import { randomUUID } from 'node:crypto';`);
|
|
669
|
+
lines.push('');
|
|
670
|
+
lines.push('/**');
|
|
671
|
+
lines.push(` * ${spec.name} — this module's own aggregate.`);
|
|
672
|
+
lines.push(' *');
|
|
673
|
+
lines.push(` * It carries exactly one tenant-scope decorator, \`@${decorator}\` (Principle XI).`);
|
|
674
|
+
lines.push(' * That is not documentation: the decorator is what registers the class with the');
|
|
675
|
+
lines.push(' * global filter, and `check-entity-tenant-classification` refuses an entity that');
|
|
676
|
+
lines.push(' * carries none or more than one — including one shipped inside an installed');
|
|
677
|
+
lines.push(' * package, read out of its `./backend` artefact.');
|
|
678
|
+
lines.push(' *');
|
|
679
|
+
lines.push(` * The table is this module's alone. Reading another module's table is a`);
|
|
680
|
+
lines.push(' * `check:module-boundary` finding whether the reach is a specifier or SQL.');
|
|
681
|
+
lines.push(' */');
|
|
682
|
+
lines.push(`@${decorator}()`);
|
|
683
|
+
lines.push(`@Entity({ tableName: ${quote(tableNameOf(spec))} })`);
|
|
684
|
+
lines.push(`export class ${entityClassOf(spec)} {`);
|
|
685
|
+
lines.push(` [OptionalProps]?: 'id' | 'createdAt' | 'updatedAt';`);
|
|
686
|
+
lines.push('');
|
|
687
|
+
lines.push(` @PrimaryKey({ type: 'uuid' })`);
|
|
688
|
+
lines.push(' id: string = randomUUID();');
|
|
689
|
+
if (tenant !== null) {
|
|
690
|
+
lines.push('');
|
|
691
|
+
lines.push(` @Property({ type: 'uuid' })`);
|
|
692
|
+
lines.push(' @Index()');
|
|
693
|
+
lines.push(` ${tenant.property}!: string;`);
|
|
694
|
+
}
|
|
695
|
+
lines.push('');
|
|
696
|
+
lines.push(` @Property({ type: 'string', length: 200 })`);
|
|
697
|
+
lines.push(' label!: string;');
|
|
698
|
+
lines.push('');
|
|
699
|
+
lines.push(` @Property({ type: 'datetime', onCreate: () => new Date() })`);
|
|
700
|
+
lines.push(' createdAt: Date = new Date();');
|
|
701
|
+
lines.push('');
|
|
702
|
+
lines.push(` @Property({ type: 'datetime', onUpdate: () => new Date() })`);
|
|
703
|
+
lines.push(' updatedAt: Date = new Date();');
|
|
704
|
+
lines.push('}');
|
|
705
|
+
lines.push('');
|
|
706
|
+
return lines.join('\n');
|
|
707
|
+
}
|
|
708
|
+
// --- src/migrations/ -------------------------------------------------------
|
|
709
|
+
function migration(spec) {
|
|
710
|
+
const table = tableNameOf(spec);
|
|
711
|
+
const tenant = tenantColumn(spec);
|
|
712
|
+
const columns = [
|
|
713
|
+
' "id" uuid not null,',
|
|
714
|
+
...(tenant === null ? [] : [` "${tenant.column}" uuid not null,`]),
|
|
715
|
+
' "label" varchar(200) not null,',
|
|
716
|
+
' "created_at" timestamptz not null,',
|
|
717
|
+
' "updated_at" timestamptz not null,',
|
|
718
|
+
` constraint "${table}_pkey" primary key ("id")`,
|
|
719
|
+
];
|
|
720
|
+
const lines = [];
|
|
721
|
+
lines.push(`import { Migration } from '@mikro-orm/migrations';`);
|
|
722
|
+
lines.push('');
|
|
723
|
+
lines.push('/**');
|
|
724
|
+
lines.push(` * ${spec.name} — initial schema.`);
|
|
725
|
+
lines.push(' *');
|
|
726
|
+
lines.push(' * The class name is derived mechanically from the file name and is scoped by the');
|
|
727
|
+
lines.push(' * owning module\'s segment, which is what keeps it unique across every module the');
|
|
728
|
+
lines.push(' * platform can compose. `mikro_orm_migrations` stores that name, so renaming it');
|
|
729
|
+
lines.push(' * after it has been applied makes every migrated database see it as pending.');
|
|
730
|
+
lines.push(' *');
|
|
731
|
+
lines.push(' * Nothing orders this migration against another module\'s except this module\'s');
|
|
732
|
+
lines.push(' * manifest `dependencies`: the timestamp orders a module\'s own migrations and');
|
|
733
|
+
lines.push(' * nothing else. A foreign key into another module\'s table needs that module');
|
|
734
|
+
lines.push(' * declared there.');
|
|
735
|
+
lines.push(' */');
|
|
736
|
+
lines.push(`export class ${migrationClassOf(spec)} extends Migration {`);
|
|
737
|
+
lines.push(' override async up(): Promise<void> {');
|
|
738
|
+
lines.push(' this.addSql(`');
|
|
739
|
+
lines.push(` create table "${table}" (`);
|
|
740
|
+
lines.push(...columns);
|
|
741
|
+
lines.push(' );');
|
|
742
|
+
lines.push(' `);');
|
|
743
|
+
if (tenant !== null) {
|
|
744
|
+
lines.push('');
|
|
745
|
+
lines.push(' this.addSql(`');
|
|
746
|
+
lines.push(` create index "${table}_${tenant.column}_idx"`);
|
|
747
|
+
lines.push(` on "${table}" ("${tenant.column}");`);
|
|
748
|
+
lines.push(' `);');
|
|
749
|
+
}
|
|
750
|
+
lines.push(' }');
|
|
751
|
+
lines.push('');
|
|
752
|
+
lines.push(' override async down(): Promise<void> {');
|
|
753
|
+
lines.push(` this.addSql('drop table if exists "${table}";');`);
|
|
754
|
+
lines.push(' }');
|
|
755
|
+
lines.push('}');
|
|
756
|
+
lines.push('');
|
|
757
|
+
return lines.join('\n');
|
|
758
|
+
}
|
|
759
|
+
function migrationsIndex(spec) {
|
|
760
|
+
const className = migrationClassOf(spec);
|
|
761
|
+
const file = migrationFileOf(spec).replace(/\.ts$/, '.js');
|
|
762
|
+
return `/**
|
|
763
|
+
* The \`./migrations\` subpath — every migration class this module owns, as one
|
|
764
|
+
* ordered \`migrations\` array.
|
|
765
|
+
*
|
|
766
|
+
* The array is what the platform reads when this module is **installed**: the
|
|
767
|
+
* package loader takes \`exported['migrations']\` and refuses the package outright
|
|
768
|
+
* when it is absent (D-168).
|
|
769
|
+
*
|
|
770
|
+
* Listed in ascending timestamp, which is the order of this module's own
|
|
771
|
+
* migrations and of nothing else: a manifest \`dependencies\` array is the only
|
|
772
|
+
* thing ordering this block against another module's.
|
|
773
|
+
*
|
|
774
|
+
* The **named** exports stay beside the array. A migration class name is
|
|
775
|
+
* contract in a way an entity class name is not — \`mikro_orm_migrations\`
|
|
776
|
+
* persists it — and the committed registry imports each class by name from this
|
|
777
|
+
* specifier. A class that is in neither the array nor the barrel is a migration
|
|
778
|
+
* that does not run: nothing reports it pending, and the first symptom is a
|
|
779
|
+
* query against a table nobody created.
|
|
780
|
+
*/
|
|
781
|
+
|
|
782
|
+
import { ${className} } from './${file}';
|
|
783
|
+
|
|
784
|
+
export const migrations = [
|
|
785
|
+
${className},
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
export {
|
|
789
|
+
${className},
|
|
790
|
+
};
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
// --- src/backend/workers/<slug>.worker.ts ----------------------------------
|
|
794
|
+
function worker(spec) {
|
|
795
|
+
const pascal = pascalOf(spec.id);
|
|
796
|
+
const queue = `${spec.id}.maintenance`;
|
|
797
|
+
return `import { Queue, Worker, type Processor, type QueueOptions, type WorkerOptions } from 'bullmq';
|
|
798
|
+
import { enterSystemScope } from '@endora-commerce/platform/kernel';
|
|
799
|
+
import type { Redis } from 'ioredis';
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* ${spec.name} — its queue consumer (Principle X).
|
|
803
|
+
*
|
|
804
|
+
* The worker is **constructed** here and **registered** through \`ctx.worker\` in
|
|
805
|
+
* \`../index.ts\`. That split matters: the registration seam is what puts the
|
|
806
|
+
* worker in the module's own registry, which is what lets the platform stop it
|
|
807
|
+
* when an operator switches the module off. A \`new Worker\` whose value goes
|
|
808
|
+
* nowhere is a queue consumer nothing in the platform can pause, and
|
|
809
|
+
* \`check:subscribe-seam\` reports one.
|
|
810
|
+
*
|
|
811
|
+
* The processor runs inside \`enterSystemScope\`, because a job has no request to
|
|
812
|
+
* inherit a tenant scope from and every non-HTTP entry point establishes its own.
|
|
813
|
+
*/
|
|
814
|
+
export interface ${pascal}JobData {
|
|
815
|
+
/** Why this sweep was enqueued, for the log line that reports it. */
|
|
816
|
+
readonly reason: string;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export interface ${pascal}JobResult {
|
|
820
|
+
readonly scanned: number;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export const ${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME = ${quote(queue)};
|
|
824
|
+
|
|
825
|
+
export function create${pascal}Queue(
|
|
826
|
+
redis: Redis,
|
|
827
|
+
overrides?: Partial<QueueOptions>,
|
|
828
|
+
): Queue<${pascal}JobData> {
|
|
829
|
+
const options: QueueOptions = {
|
|
830
|
+
connection: redis,
|
|
831
|
+
defaultJobOptions: {
|
|
832
|
+
attempts: 5,
|
|
833
|
+
backoff: { type: 'exponential', delay: 1_000 },
|
|
834
|
+
removeOnComplete: { count: 100 },
|
|
835
|
+
removeOnFail: { count: 1_000 },
|
|
836
|
+
},
|
|
837
|
+
...overrides,
|
|
838
|
+
};
|
|
839
|
+
return new Queue<${pascal}JobData>(${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME, options);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export function create${pascal}Worker(
|
|
843
|
+
redis: Redis,
|
|
844
|
+
processor: Processor<${pascal}JobData, ${pascal}JobResult>,
|
|
845
|
+
overrides?: Partial<WorkerOptions>,
|
|
846
|
+
): Worker<${pascal}JobData, ${pascal}JobResult> {
|
|
847
|
+
const options: WorkerOptions = {
|
|
848
|
+
connection: redis,
|
|
849
|
+
concurrency: 1,
|
|
850
|
+
...overrides,
|
|
851
|
+
};
|
|
852
|
+
return new Worker<${pascal}JobData, ${pascal}JobResult>(
|
|
853
|
+
${segmentOf(spec.id).toUpperCase()}_QUEUE_NAME,
|
|
854
|
+
(job) => enterSystemScope(${quote(`${spec.id}: maintenance sweep`)}, () => processor(job)),
|
|
855
|
+
options,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
`;
|
|
859
|
+
}
|
|
860
|
+
// --- src/ports/index.ts ----------------------------------------------------
|
|
861
|
+
function ports(spec) {
|
|
862
|
+
const pascal = pascalOf(spec.id);
|
|
863
|
+
const shape = spec.layers.entities
|
|
864
|
+
? `/** One row as this module hands it out: never the managed entity itself. */
|
|
865
|
+
export interface ${pascal}Record {
|
|
866
|
+
readonly id: string;
|
|
867
|
+
readonly label: string;
|
|
868
|
+
readonly createdAt: string;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export interface ${portInterfaceOf(spec)} {
|
|
872
|
+
list(): Promise<readonly ${pascal}Record[]>;
|
|
873
|
+
}
|
|
874
|
+
`
|
|
875
|
+
: `/** What this module reports about itself. */
|
|
876
|
+
export interface ${pascal}Status {
|
|
877
|
+
readonly module: string;
|
|
878
|
+
readonly name: string;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export interface ${portInterfaceOf(spec)} {
|
|
882
|
+
status(): ${pascal}Status;
|
|
883
|
+
}
|
|
884
|
+
`;
|
|
885
|
+
return `/**
|
|
886
|
+
* ${spec.name} — the contract surface other modules resolve.
|
|
887
|
+
*
|
|
888
|
+
* **Type-only, and that is the whole design of this subpath.** A module package
|
|
889
|
+
* whose \`./ports\` module exports no runtime binding is contract surface (D-171):
|
|
890
|
+
* a consumer's \`import type\` from it is not a cross-module boundary reach, so
|
|
891
|
+
* publishing an interface here is what turns a coupling into a declaration.
|
|
892
|
+
* Adding a \`const\`, a class or a function to this file takes that back, in the
|
|
893
|
+
* same run.
|
|
894
|
+
*
|
|
895
|
+
* The container name is contract too: this interface is registered under
|
|
896
|
+
* \`${portNameOf(spec)}\`, and that literal is what a consumer writes in
|
|
897
|
+
* \`lazyPort<${portInterfaceOf(spec)}>(ctx, '${portNameOf(spec)}')\`. A consumer that
|
|
898
|
+
* resolves it declares this module in its manifest \`dependencies\`, which is what
|
|
899
|
+
* makes the edge real to the lifecycle and to an operator switching this module
|
|
900
|
+
* off.
|
|
901
|
+
*
|
|
902
|
+
* No method here is optional. Feature detection through a port is impossible by
|
|
903
|
+
* construction — the resolution proxy answers every property with a function, so
|
|
904
|
+
* \`if (port.maybe)\` is always true and the forward throws when the provider has
|
|
905
|
+
* none.
|
|
906
|
+
*/
|
|
907
|
+
|
|
908
|
+
${shape}`;
|
|
909
|
+
}
|
|
910
|
+
// --- src/admin/ ------------------------------------------------------------
|
|
911
|
+
/**
|
|
912
|
+
* `src/admin/index.ts` — the module's admin contributions, and **only** the
|
|
913
|
+
* contributions object.
|
|
914
|
+
*
|
|
915
|
+
* `check:module-boundary`'s D-171 rule designates a subpath as contract surface
|
|
916
|
+
* exactly while the module it resolves to emits no runtime binding. `./admin`
|
|
917
|
+
* deliberately does not qualify — it exports an object — so a consumer reaching
|
|
918
|
+
* into another module's `./admin` stays a counted boundary reach, which is the
|
|
919
|
+
* correct answer. That is what a component, a hook or a service exported from
|
|
920
|
+
* here would quietly turn into a supported pattern.
|
|
921
|
+
*/
|
|
922
|
+
function adminIndex(spec) {
|
|
923
|
+
const gate = gatingPermissionOf(spec);
|
|
924
|
+
const route = adminScreenRouteOf(spec);
|
|
925
|
+
const section = spec.layers.admin;
|
|
926
|
+
if (gate === null || route === null || section === null) {
|
|
927
|
+
throw new Error('adminIndex emitted without a gated route to mount the screen on');
|
|
928
|
+
}
|
|
929
|
+
const lines = [];
|
|
930
|
+
lines.push(`import type { AdminContributions } from '@endora-commerce/admin-kit/contributions';`);
|
|
931
|
+
lines.push('');
|
|
932
|
+
lines.push('/**');
|
|
933
|
+
lines.push(` * ${spec.name} — the module's admin surface.`);
|
|
934
|
+
lines.push(' *');
|
|
935
|
+
lines.push(' * The admin renders `[...host, ...registry]`: the generated registry');
|
|
936
|
+
lines.push(' * `admin/src/modules.generated.ts` imports this object by the bare specifier');
|
|
937
|
+
lines.push(' * this package\'s own `exports` map declares, so a screen arrives by the module');
|
|
938
|
+
lines.push(' * existing. Nothing here is registered in a file the module does not own —');
|
|
939
|
+
lines.push(' * `admin/src/App.tsx` and `admin/src/components/AppShell.tsx` are the two');
|
|
940
|
+
lines.push(' * registries this replaces.');
|
|
941
|
+
lines.push(' *');
|
|
942
|
+
lines.push(' * **This file exports data and nothing else.** A component, a hook or a');
|
|
943
|
+
lines.push(' * service exported here would make another module\'s reach into this layer a');
|
|
944
|
+
lines.push(' * supported pattern rather than the counted boundary reach it is.');
|
|
945
|
+
lines.push(' *');
|
|
946
|
+
lines.push(' * **The component is a dynamic-import factory, and it is the only');
|
|
947
|
+
lines.push(' * function-valued field.** A static import would make the registry evaluate');
|
|
948
|
+
lines.push(' * React to be enumerated, and would ship this screen\'s code to an operator');
|
|
949
|
+
lines.push(' * whose role cannot open it; the factory is what makes the bundler emit one');
|
|
950
|
+
lines.push(' * chunk per module by construction.');
|
|
951
|
+
lines.push(' */');
|
|
952
|
+
lines.push('');
|
|
953
|
+
lines.push("/** The one route this module mounts, and the palette action's target. */");
|
|
954
|
+
lines.push(`const ROUTE_PATH = ${quote(route)};`);
|
|
955
|
+
lines.push('');
|
|
956
|
+
lines.push('export const contributions: AdminContributions = {');
|
|
957
|
+
lines.push(' routes: [');
|
|
958
|
+
lines.push(' {');
|
|
959
|
+
lines.push(' path: ROUTE_PATH,');
|
|
960
|
+
lines.push(` component: () => import('./pages/${pageComponentOf(spec)}.js'),`);
|
|
961
|
+
lines.push(' // The code this module\'s own admin routes are gated by, read from the');
|
|
962
|
+
lines.push(' // route rather than copied from a neighbour: `requireAdmin` enforces it');
|
|
963
|
+
lines.push(' // on `/api/v1/admin` + this path, and');
|
|
964
|
+
lines.push(' // `check:action-route-permissions` compares the two.');
|
|
965
|
+
lines.push(` requiredPermission: ${quote(gate.code)},`);
|
|
966
|
+
lines.push(' index: true,');
|
|
967
|
+
lines.push(' },');
|
|
968
|
+
lines.push(' ],');
|
|
969
|
+
lines.push(' nav: [');
|
|
970
|
+
lines.push(' {');
|
|
971
|
+
lines.push(' to: ROUTE_PATH,');
|
|
972
|
+
lines.push(' // Module-relative, resolved in this module\'s own i18n namespace out of');
|
|
973
|
+
lines.push(' // `i18n/en.json` and `i18n/pl.json`. A key written as a nested object');
|
|
974
|
+
lines.push(' // fails the bundle schema, the boot reconciler logs and skips it, and');
|
|
975
|
+
lines.push(' // the sidebar renders the raw key with no error anywhere.');
|
|
976
|
+
lines.push(` labelKey: ${quote(navLabelKeyOf(spec))},`);
|
|
977
|
+
lines.push(` icon: ${quote(spec.icon)},`);
|
|
978
|
+
lines.push(' // A module may not invent a section: an invented heading is one no other');
|
|
979
|
+
lines.push(' // module can join, so two features that belong together become two');
|
|
980
|
+
lines.push(' // headings of one each.');
|
|
981
|
+
lines.push(` section: ${quote(section)},`);
|
|
982
|
+
lines.push(' // Order within the section. Ties break by module id, so it is stable.');
|
|
983
|
+
lines.push(' weight: 100,');
|
|
984
|
+
lines.push(' // The same code the route is gated by, so the sidebar never advertises a');
|
|
985
|
+
lines.push(' // screen the operator would be refused.');
|
|
986
|
+
lines.push(` requiredPermission: ${quote(gate.code)},`);
|
|
987
|
+
lines.push(' },');
|
|
988
|
+
lines.push(' ],');
|
|
989
|
+
lines.push('};');
|
|
990
|
+
lines.push('');
|
|
991
|
+
return lines.join('\n');
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* The screen itself — small enough to read, and real enough to prove the wiring.
|
|
995
|
+
*
|
|
996
|
+
* It renders through the published design system, resolves every string in this
|
|
997
|
+
* module's own namespace, and reads this module's own admin endpoint through
|
|
998
|
+
* the published client. That is four separate seams, each of which fails
|
|
999
|
+
* differently when it is wrong, and none of which is asserted by the module
|
|
1000
|
+
* type-checking.
|
|
1001
|
+
*/
|
|
1002
|
+
function adminPage(spec) {
|
|
1003
|
+
const pascal = pascalOf(spec.id);
|
|
1004
|
+
const route = adminScreenRouteOf(spec);
|
|
1005
|
+
if (route === null)
|
|
1006
|
+
throw new Error('adminPage emitted without a route to read');
|
|
1007
|
+
const component = pageComponentOf(spec);
|
|
1008
|
+
const lines = [];
|
|
1009
|
+
lines.push(`import { useCallback, useEffect, useState, type ReactNode } from 'react';`);
|
|
1010
|
+
lines.push(`import { ApiError, apiClient } from '@endora-commerce/admin-kit/lib';`);
|
|
1011
|
+
lines.push('import {');
|
|
1012
|
+
lines.push(' Alert,');
|
|
1013
|
+
lines.push(' AlertDescription,');
|
|
1014
|
+
lines.push(' Card,');
|
|
1015
|
+
lines.push(' CardContent,');
|
|
1016
|
+
lines.push(' PageHeader,');
|
|
1017
|
+
if (spec.layers.entities) {
|
|
1018
|
+
lines.push(' Table,');
|
|
1019
|
+
lines.push(' TableBody,');
|
|
1020
|
+
lines.push(' TableCell,');
|
|
1021
|
+
lines.push(' TableHead,');
|
|
1022
|
+
lines.push(' TableHeader,');
|
|
1023
|
+
lines.push(' TableRow,');
|
|
1024
|
+
}
|
|
1025
|
+
lines.push(`} from '@endora-commerce/admin-kit/ui';`);
|
|
1026
|
+
lines.push(`import { useTranslation } from '@endora-commerce/admin-kit/i18n';`);
|
|
1027
|
+
lines.push('');
|
|
1028
|
+
lines.push('/**');
|
|
1029
|
+
lines.push(` * ${spec.name} — the module's own admin screen.`);
|
|
1030
|
+
lines.push(' *');
|
|
1031
|
+
lines.push(' * Every specifier above is a **bare** one. `@/…` is the admin application\'s');
|
|
1032
|
+
lines.push(' * own tsconfig and Vite alias: it resolves for a file under `admin/src` and');
|
|
1033
|
+
lines.push(' * for nothing an installed package runs under, which is why');
|
|
1034
|
+
lines.push(' * `check:admin-surface` reports one from here as an `aliased-reach`.');
|
|
1035
|
+
lines.push(' *');
|
|
1036
|
+
lines.push(' * The server is reached only through `apiClient`, which already carries the');
|
|
1037
|
+
lines.push(' * admin\'s API origin. A screen that has to build a URL itself — an');
|
|
1038
|
+
lines.push(' * `<a download>` href, a form action — takes `apiBaseUrl` from');
|
|
1039
|
+
lines.push(' * `@endora-commerce/admin-kit/lib` and never `import.meta.env`: reading the');
|
|
1040
|
+
lines.push(' * environment here would mean a second copy of that fallback plus a');
|
|
1041
|
+
lines.push(' * `vite/client` type dependency this package must not acquire.');
|
|
1042
|
+
lines.push(' *');
|
|
1043
|
+
lines.push(' * Every string resolves in this module\'s own i18n namespace out of');
|
|
1044
|
+
lines.push(' * `i18n/en.json` and `i18n/pl.json`, which the platform\'s boot reconciler');
|
|
1045
|
+
lines.push(' * installs. A module writes into no shared bundle.');
|
|
1046
|
+
lines.push(' */');
|
|
1047
|
+
lines.push('');
|
|
1048
|
+
if (spec.layers.entities) {
|
|
1049
|
+
lines.push(`/** One row, as \`GET ${adminApiPathFor(route)}\` answers. */`);
|
|
1050
|
+
lines.push(`interface ${pascal}Row {`);
|
|
1051
|
+
lines.push(' readonly id: string;');
|
|
1052
|
+
lines.push(' readonly label: string;');
|
|
1053
|
+
lines.push(' readonly createdAt: string;');
|
|
1054
|
+
lines.push('}');
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
lines.push(`/** What \`GET ${adminApiPathFor(route)}\` answers. */`);
|
|
1058
|
+
lines.push(`interface ${pascal}Status {`);
|
|
1059
|
+
lines.push(' readonly module: string;');
|
|
1060
|
+
lines.push(' readonly name: string;');
|
|
1061
|
+
lines.push('}');
|
|
1062
|
+
}
|
|
1063
|
+
lines.push('');
|
|
1064
|
+
lines.push(`export default function ${component}(): ReactNode {`);
|
|
1065
|
+
lines.push(` const t = useTranslation(${quote(spec.id)});`);
|
|
1066
|
+
if (spec.layers.entities) {
|
|
1067
|
+
lines.push(` const [rows, setRows] = useState<readonly ${pascal}Row[]>([]);`);
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
lines.push(` const [status, setStatus] = useState<${pascal}Status | null>(null);`);
|
|
1071
|
+
}
|
|
1072
|
+
lines.push(' const [loading, setLoading] = useState(true);');
|
|
1073
|
+
lines.push(' const [error, setError] = useState<string | null>(null);');
|
|
1074
|
+
lines.push('');
|
|
1075
|
+
lines.push(' const load = useCallback(async (): Promise<void> => {');
|
|
1076
|
+
lines.push(' setLoading(true);');
|
|
1077
|
+
lines.push(' setError(null);');
|
|
1078
|
+
lines.push(' try {');
|
|
1079
|
+
if (spec.layers.entities) {
|
|
1080
|
+
lines.push(` const res = await apiClient.get<{ data: ${pascal}Row[] }>(`);
|
|
1081
|
+
lines.push(` ${quote(adminApiPathFor(route))},`);
|
|
1082
|
+
lines.push(' );');
|
|
1083
|
+
lines.push(' setRows(res.data);');
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
lines.push(` const res = await apiClient.get<{ data: ${pascal}Status }>(`);
|
|
1087
|
+
lines.push(` ${quote(adminApiPathFor(route))},`);
|
|
1088
|
+
lines.push(' );');
|
|
1089
|
+
lines.push(' setStatus(res.data);');
|
|
1090
|
+
}
|
|
1091
|
+
lines.push(' } catch (err) {');
|
|
1092
|
+
lines.push(' // The envelope carries a translated, operator-facing sentence; the');
|
|
1093
|
+
lines.push(' // module\'s own key is the fallback for a failure that produced none.');
|
|
1094
|
+
lines.push(" setError(err instanceof ApiError ? err.envelope.error.message : t('admin.error'));");
|
|
1095
|
+
lines.push(' } finally {');
|
|
1096
|
+
lines.push(' setLoading(false);');
|
|
1097
|
+
lines.push(' }');
|
|
1098
|
+
lines.push(' }, [t]);');
|
|
1099
|
+
lines.push('');
|
|
1100
|
+
lines.push(' useEffect(() => {');
|
|
1101
|
+
lines.push(' void load();');
|
|
1102
|
+
lines.push(' }, [load]);');
|
|
1103
|
+
lines.push('');
|
|
1104
|
+
lines.push(' let body: ReactNode = (');
|
|
1105
|
+
lines.push(" <p className=\"text-sm text-muted-foreground\">{t('admin.loading')}</p>");
|
|
1106
|
+
lines.push(' );');
|
|
1107
|
+
if (spec.layers.entities) {
|
|
1108
|
+
lines.push(' if (!loading) {');
|
|
1109
|
+
lines.push(' body = (');
|
|
1110
|
+
lines.push(' <Table>');
|
|
1111
|
+
lines.push(' <TableHeader>');
|
|
1112
|
+
lines.push(' <TableRow>');
|
|
1113
|
+
lines.push(" <TableHead>{t('admin.column.label')}</TableHead>");
|
|
1114
|
+
lines.push(" <TableHead>{t('admin.column.createdAt')}</TableHead>");
|
|
1115
|
+
lines.push(' </TableRow>');
|
|
1116
|
+
lines.push(' </TableHeader>');
|
|
1117
|
+
lines.push(' <TableBody>');
|
|
1118
|
+
lines.push(' {rows.length === 0 ? (');
|
|
1119
|
+
lines.push(' <TableRow>');
|
|
1120
|
+
lines.push(' <TableCell colSpan={2} className="text-sm text-muted-foreground">');
|
|
1121
|
+
lines.push(" {t('admin.empty')}");
|
|
1122
|
+
lines.push(' </TableCell>');
|
|
1123
|
+
lines.push(' </TableRow>');
|
|
1124
|
+
lines.push(' ) : (');
|
|
1125
|
+
lines.push(' rows.map((row) => (');
|
|
1126
|
+
lines.push(' <TableRow key={row.id}>');
|
|
1127
|
+
lines.push(' <TableCell>{row.label}</TableCell>');
|
|
1128
|
+
lines.push(' <TableCell>{new Date(row.createdAt).toLocaleString()}</TableCell>');
|
|
1129
|
+
lines.push(' </TableRow>');
|
|
1130
|
+
lines.push(' ))');
|
|
1131
|
+
lines.push(' )}');
|
|
1132
|
+
lines.push(' </TableBody>');
|
|
1133
|
+
lines.push(' </Table>');
|
|
1134
|
+
lines.push(' );');
|
|
1135
|
+
lines.push(' }');
|
|
1136
|
+
}
|
|
1137
|
+
else {
|
|
1138
|
+
lines.push(' if (!loading && status !== null) {');
|
|
1139
|
+
lines.push(' body = (');
|
|
1140
|
+
lines.push(' <dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">');
|
|
1141
|
+
lines.push(" <dt className=\"text-muted-foreground\">{t('admin.field.module')}</dt>");
|
|
1142
|
+
lines.push(' <dd>{status.module}</dd>');
|
|
1143
|
+
lines.push(" <dt className=\"text-muted-foreground\">{t('admin.field.name')}</dt>");
|
|
1144
|
+
lines.push(' <dd>{status.name}</dd>');
|
|
1145
|
+
lines.push(' </dl>');
|
|
1146
|
+
lines.push(' );');
|
|
1147
|
+
lines.push(' }');
|
|
1148
|
+
}
|
|
1149
|
+
lines.push('');
|
|
1150
|
+
lines.push(' return (');
|
|
1151
|
+
lines.push(' <div className="space-y-6">');
|
|
1152
|
+
lines.push(" <PageHeader title={t('admin.title')} description={t('admin.description')} />");
|
|
1153
|
+
lines.push(' {error === null ? null : (');
|
|
1154
|
+
lines.push(' <Alert variant="destructive">');
|
|
1155
|
+
lines.push(' <AlertDescription>{error}</AlertDescription>');
|
|
1156
|
+
lines.push(' </Alert>');
|
|
1157
|
+
lines.push(' )}');
|
|
1158
|
+
lines.push(' <Card>');
|
|
1159
|
+
lines.push(' <CardContent className="pt-6">{body}</CardContent>');
|
|
1160
|
+
lines.push(' </Card>');
|
|
1161
|
+
lines.push(' </div>');
|
|
1162
|
+
lines.push(' );');
|
|
1163
|
+
lines.push('}');
|
|
1164
|
+
lines.push('');
|
|
1165
|
+
return lines.join('\n');
|
|
1166
|
+
}
|
|
1167
|
+
// --- test/unit/admin-contributions.test.ts ---------------------------------
|
|
1168
|
+
/**
|
|
1169
|
+
* What an admin contribution can be wrong about without anything else noticing.
|
|
1170
|
+
*
|
|
1171
|
+
* The type-check proves the declaration's *shape*; none of these is a shape. A
|
|
1172
|
+
* nav entry pointing at a path no route declares renders a blank screen, a
|
|
1173
|
+
* `labelKey` missing from one language renders the raw key in that language
|
|
1174
|
+
* only, and a `requiredPermission` the manifest does not declare is a sidebar
|
|
1175
|
+
* row nobody can ever be granted. The layer's own build says nothing about any
|
|
1176
|
+
* of them.
|
|
1177
|
+
*/
|
|
1178
|
+
function adminTest(spec) {
|
|
1179
|
+
return `import { describe, expect, it } from 'vitest';
|
|
1180
|
+
import { readFileSync } from 'node:fs';
|
|
1181
|
+
import { dirname } from 'node:path';
|
|
1182
|
+
import { fileURLToPath } from 'node:url';
|
|
1183
|
+
import {
|
|
1184
|
+
AdminNavDeclarationSchema,
|
|
1185
|
+
AdminRouteDeclarationSchema,
|
|
1186
|
+
} from '@endora-commerce/contracts';
|
|
1187
|
+
import * as adminLayer from '../../src/admin/index.js';
|
|
1188
|
+
import { manifest } from '../../src/manifest.js';
|
|
1189
|
+
|
|
1190
|
+
const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
1191
|
+
|
|
1192
|
+
function readBundle(language: string): Record<string, unknown> {
|
|
1193
|
+
return JSON.parse(
|
|
1194
|
+
readFileSync(\`\${packageDir}/i18n/\${language}.json\`, 'utf8'),
|
|
1195
|
+
) as Record<string, unknown>;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
const bundles = { en: readBundle('en'), pl: readBundle('pl') };
|
|
1199
|
+
const { contributions } = adminLayer;
|
|
1200
|
+
|
|
1201
|
+
describe('${spec.id} admin contributions', () => {
|
|
1202
|
+
it('exports the contributions object and nothing else', () => {
|
|
1203
|
+
// A subpath is contract surface exactly while its module emits no runtime
|
|
1204
|
+
// binding, and \`./admin\` deliberately is not — it exports an object. A
|
|
1205
|
+
// component or a hook exported beside it would turn another module's reach
|
|
1206
|
+
// into this layer from a counted boundary reach into a supported pattern.
|
|
1207
|
+
expect(Object.keys(adminLayer)).toEqual(['contributions']);
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
it('declares every route as a dynamic-import factory', () => {
|
|
1211
|
+
for (const route of contributions.routes ?? []) {
|
|
1212
|
+
const { component, ...data } = route;
|
|
1213
|
+
|
|
1214
|
+
expect(AdminRouteDeclarationSchema.safeParse(data).success, data.path).toBe(true);
|
|
1215
|
+
expect(typeof component, data.path).toBe('function');
|
|
1216
|
+
expect(component.length, data.path).toBe(0);
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it('points every nav entry at a route this module declares', () => {
|
|
1221
|
+
const paths = new Set((contributions.routes ?? []).map((route) => route.path));
|
|
1222
|
+
|
|
1223
|
+
for (const item of contributions.nav ?? []) {
|
|
1224
|
+
expect(AdminNavDeclarationSchema.safeParse(item).success, item.to).toBe(true);
|
|
1225
|
+
expect(paths.has(item.to), item.to).toBe(true);
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('resolves every nav label in both shipped languages', () => {
|
|
1230
|
+
for (const item of contributions.nav ?? []) {
|
|
1231
|
+
for (const [language, entries] of Object.entries(bundles)) {
|
|
1232
|
+
expect(entries[item.labelKey], \`\${language}:\${item.to}\`).toEqual(expect.any(String));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it('gates every surface on a permission this module declares', () => {
|
|
1238
|
+
const declared = new Set((manifest.permissions ?? []).map((entry) => entry.code));
|
|
1239
|
+
const required = [...(contributions.routes ?? []), ...(contributions.nav ?? [])].flatMap(
|
|
1240
|
+
(surface) =>
|
|
1241
|
+
surface.requiredPermission === undefined
|
|
1242
|
+
? []
|
|
1243
|
+
: [surface.requiredPermission].flat(),
|
|
1244
|
+
);
|
|
1245
|
+
|
|
1246
|
+
expect(required.length).toBeGreaterThan(0);
|
|
1247
|
+
for (const code of required) expect(declared.has(code), code).toBe(true);
|
|
1248
|
+
});
|
|
1249
|
+
});
|
|
1250
|
+
`;
|
|
1251
|
+
}
|
|
1252
|
+
// --- i18n/{en,pl}.json -----------------------------------------------------
|
|
1253
|
+
/** `open-widgets` → `Open widgets`, the mechanical default label for an action. */
|
|
1254
|
+
function titleOfActionId(actionId) {
|
|
1255
|
+
const words = actionId.split('-').filter((part) => part.length > 0);
|
|
1256
|
+
const first = words[0] ?? actionId;
|
|
1257
|
+
return [first.charAt(0).toUpperCase() + first.slice(1), ...words.slice(1)].join(' ');
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* The keys a bundle carries, in the two namespaces a module's own bundle mixes.
|
|
1261
|
+
*
|
|
1262
|
+
* `actions.*` is **module-relative** — the palette resolves it under this
|
|
1263
|
+
* module's own namespace. `adminRoles.permission.<code>` is **platform-global**
|
|
1264
|
+
* and is written into this module's bundle anyway, because a package cannot
|
|
1265
|
+
* write into the platform's `_i18n` bundle at all. The split is invisible from
|
|
1266
|
+
* the file, which is why it is stated here.
|
|
1267
|
+
*
|
|
1268
|
+
* `nav.*` and `admin.*` are module-relative too, and arrive with the admin
|
|
1269
|
+
* layer: the sidebar resolves a contributed entry's `labelKey` in the
|
|
1270
|
+
* contributing module's namespace, so a module writes into no shared bundle to
|
|
1271
|
+
* be named in the shell.
|
|
1272
|
+
*
|
|
1273
|
+
* Both languages carry the same keys. The Polish values are the author's own
|
|
1274
|
+
* English text: a translation is a human judgement and this command does not
|
|
1275
|
+
* invent one — it names `i18n/pl.json` in its next steps instead.
|
|
1276
|
+
*/
|
|
1277
|
+
export function bundleEntries(spec) {
|
|
1278
|
+
const entries = [['module.name', spec.name]];
|
|
1279
|
+
for (const permission of spec.permissions) {
|
|
1280
|
+
entries.push([`adminRoles.permission.${permission.code}`, permission.label]);
|
|
1281
|
+
}
|
|
1282
|
+
for (const action of spec.actions) {
|
|
1283
|
+
const key = camelOfActionId(action.id);
|
|
1284
|
+
entries.push([`actions.${key}.label`, titleOfActionId(action.id)]);
|
|
1285
|
+
entries.push([`actions.${key}.description`, spec.description]);
|
|
1286
|
+
}
|
|
1287
|
+
if (spec.layers.admin !== null) {
|
|
1288
|
+
entries.push([navLabelKeyOf(spec), spec.name]);
|
|
1289
|
+
entries.push(['admin.title', spec.name]);
|
|
1290
|
+
entries.push(['admin.description', spec.description]);
|
|
1291
|
+
entries.push(['admin.loading', 'Loading…']);
|
|
1292
|
+
entries.push(['admin.error', `Could not load ${spec.name}.`]);
|
|
1293
|
+
if (spec.layers.entities) {
|
|
1294
|
+
entries.push(['admin.column.label', 'Label']);
|
|
1295
|
+
entries.push(['admin.column.createdAt', 'Created']);
|
|
1296
|
+
entries.push(['admin.empty', `No ${spec.name} yet.`]);
|
|
1297
|
+
}
|
|
1298
|
+
else {
|
|
1299
|
+
entries.push(['admin.field.module', 'Module']);
|
|
1300
|
+
entries.push(['admin.field.name', 'Name']);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return entries;
|
|
1304
|
+
}
|
|
1305
|
+
function bundle(spec) {
|
|
1306
|
+
return flatJson(bundleEntries(spec));
|
|
1307
|
+
}
|
|
1308
|
+
// --- test/unit/manifest.test.ts --------------------------------------------
|
|
1309
|
+
function manifestTest(spec) {
|
|
1310
|
+
return `import { describe, expect, it } from 'vitest';
|
|
1311
|
+
import { readFileSync } from 'node:fs';
|
|
1312
|
+
import { basename, dirname } from 'node:path';
|
|
1313
|
+
import { fileURLToPath } from 'node:url';
|
|
1314
|
+
import { manifest } from '../../src/manifest.js';
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* What a module manifest can be wrong about without anything else noticing.
|
|
1318
|
+
*
|
|
1319
|
+
* Every assertion here is a rule the platform enforces at a moment that is too
|
|
1320
|
+
* late to be useful: the id disagreement is diagnosed at boot, a missing
|
|
1321
|
+
* permission label renders as a raw key on /admin-roles, a nested i18n bundle is
|
|
1322
|
+
* *logged and skipped* by the boot reconciler so the palette renders raw keys
|
|
1323
|
+
* with no error anywhere, and an action key that resolves in one language and not
|
|
1324
|
+
* the other is invisible until somebody switches language.
|
|
1325
|
+
*/
|
|
1326
|
+
|
|
1327
|
+
const packageDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
1328
|
+
|
|
1329
|
+
function readJson(relative: string): Record<string, unknown> {
|
|
1330
|
+
return JSON.parse(readFileSync(\`\${packageDir}/\${relative}\`, 'utf8')) as Record<string, unknown>;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const packageManifest = readJson('package.json');
|
|
1334
|
+
const bundles = {
|
|
1335
|
+
en: readJson('i18n/en.json'),
|
|
1336
|
+
pl: readJson('i18n/pl.json'),
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
describe('${spec.id} manifest', () => {
|
|
1340
|
+
it('agrees with the package and the directory about its own id', () => {
|
|
1341
|
+
const endora = packageManifest['endora'] as { id?: unknown } | undefined;
|
|
1342
|
+
|
|
1343
|
+
expect(manifest.id).toBe(${quote(spec.id)});
|
|
1344
|
+
expect(endora?.id).toBe(manifest.id);
|
|
1345
|
+
expect(basename(packageDir)).toBe(manifest.id);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
it('declares an activation control or says why it has none', () => {
|
|
1349
|
+
const activation = manifest.activation;
|
|
1350
|
+
|
|
1351
|
+
expect(activation).toBeDefined();
|
|
1352
|
+
if (activation !== undefined && 'nonDeactivatable' in activation) {
|
|
1353
|
+
expect(activation.reason.length).toBeGreaterThan(0);
|
|
1354
|
+
} else {
|
|
1355
|
+
expect(activation).toMatchObject({ default: expect.any(Boolean) });
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('ships flat bundles in both languages', () => {
|
|
1360
|
+
for (const [language, entries] of Object.entries(bundles)) {
|
|
1361
|
+
expect(Object.keys(entries).length, language).toBeGreaterThan(0);
|
|
1362
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
1363
|
+
expect(typeof value, \`\${language}:\${key}\`).toBe('string');
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('has a label for every permission it declares, in both languages', () => {
|
|
1369
|
+
for (const permission of manifest.permissions ?? []) {
|
|
1370
|
+
for (const [language, entries] of Object.entries(bundles)) {
|
|
1371
|
+
expect(entries[\`adminRoles.permission.\${permission.code}\`], \`\${language}\`).toEqual(
|
|
1372
|
+
expect.any(String),
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it('resolves every palette action key in both languages', () => {
|
|
1379
|
+
for (const action of manifest.actions ?? []) {
|
|
1380
|
+
for (const [language, entries] of Object.entries(bundles)) {
|
|
1381
|
+
expect(entries[action.labelKey], \`\${language}:\${action.id}\`).toEqual(expect.any(String));
|
|
1382
|
+
if (action.descriptionKey !== undefined) {
|
|
1383
|
+
expect(entries[action.descriptionKey], \`\${language}:\${action.id}\`).toEqual(
|
|
1384
|
+
expect.any(String),
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
`;
|
|
1392
|
+
}
|
|
1393
|
+
//# sourceMappingURL=emit.js.map
|