@starci/eslint-canon-be 3.0.8 → 3.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -1
- package/README.md +12 -6
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +18 -21
- package/config-owner.mjs +20 -4
- package/connections.mjs +12 -12
- package/cookies.mjs +106 -0
- package/cqrs.mjs +22 -26
- package/data-access.mjs +9 -28
- package/default-deny.mjs +2 -1
- package/e2e-flow.mjs +4 -4
- package/event-bus.mjs +237 -0
- package/feature-thin.mjs +237 -0
- package/idempotency.mjs +3 -1
- package/index.mjs +22 -1
- package/infra-owner.mjs +1 -1
- package/injection.mjs +67 -22
- package/input-bounds.mjs +2 -1
- package/jobs.mjs +214 -0
- package/lib/ast.mjs +7 -0
- package/lib/config.mjs +1 -1
- package/lib/db-writes.mjs +1 -1
- package/lib/doors.mjs +81 -0
- package/lib/edition.mjs +45 -0
- package/lib/entity-writes.mjs +35 -0
- package/lib/path.mjs +0 -6
- package/lib/persistence.mjs +11 -18
- package/lib/ports.mjs +5 -2
- package/lib/specifier.mjs +27 -0
- package/lib/transactions.mjs +37 -0
- package/lib/transport-slots.mjs +8 -3
- package/lib/unit-spec.mjs +63 -2
- package/module-layering.mjs +3 -24
- package/module-shape.mjs +2 -2
- package/naming.mjs +2 -2
- package/package.json +3 -1
- package/project-graph.mjs +5 -2
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +494 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +160 -29
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +912 -120
- package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
- package/runtime/knowledge/sonar-gate.yaml +52 -18
- package/runtime/modules/models/registry.yaml +591 -0
- package/runtime/modules/models/runtimes.yaml +701 -0
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
- package/runtime/scripts/api/git/config-get.mjs +10 -0
- package/runtime/scripts/api/git/lib.mjs +16 -26
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
- package/runtime/scripts/hfs/architecture/config.mjs +9 -6
- package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
- package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
- package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
- package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +1 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
- package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +17 -4
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
- package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
- package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
- package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
- package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
- package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
- package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
- package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
- package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
- package/runtime/scripts/hfs/edition-slots.mjs +66 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/path-findings.mjs +1 -1
- package/runtime/scripts/hfs/project-graph.mjs +3 -3
- package/runtime/scripts/hfs/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
- package/runtime/scripts/hfs/rules/read.mjs +1 -1
- package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
- package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
- package/runtime/scripts/hfs/slots.mjs +78 -108
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
- package/runtime/scripts/hfs/view.mjs +3 -2
- package/runtime/scripts/lib/dockerfile.mjs +124 -0
- package/runtime/scripts/lib/git.mjs +36 -0
- package/runtime/scripts/lib/i18n.mjs +86 -0
- package/runtime/scripts/lib/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +24 -5
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +57 -2
- package/runtime/scripts/lib/read-text.mjs +34 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +4 -3
- package/runtime/scripts/lib/sops-envelope.mjs +58 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/lib/walk.mjs +4 -3
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +1 -1
- package/test-world.mjs +5 -4
- package/testing.mjs +27 -24
- package/transport.mjs +12 -9
- package/unit-spec.mjs +1 -1
- package/user-copy.mjs +5 -4
- package/webhooks.mjs +173 -0
- package/runtime/scripts/api/sops/decrypt.mjs +0 -16
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
package/cookies.mjs
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds the cookie half of the back-end transport law (catalog R141 `BE_COOKIE_ATTRIBUTES`).
|
|
3
|
+
*
|
|
4
|
+
* No back-end door writes a cookie under the canon today (the session travels as a bearer or as the front end's own httpOnly
|
|
5
|
+
* cookie), so this law is built ahead of the first one: a cookie a door writes through the response of Express or Fastify
|
|
6
|
+
* states `httpOnly: true`, carries `secure` and has `sameSite` `lax` or `strict`, judged on the TYPE of its options (a spread
|
|
7
|
+
* `as const` constant keeps its literals), and a `Set-Cookie` header is never written by hand, because a hand-written header
|
|
8
|
+
* states none of the three. The call is recognised by where its signature is declared (the `express` or `fastify` family of
|
|
9
|
+
* packages), never by the name of the receiver.
|
|
10
|
+
*/
|
|
11
|
+
import ts from "typescript"
|
|
12
|
+
import { moduleOf, typed } from "./lib/types.mjs"
|
|
13
|
+
|
|
14
|
+
/** The packages whose response types declare the cookie writers: Express with its typings, and Fastify with its cookie plugin. */
|
|
15
|
+
const RESPONSE_PACKAGES = new Set([
|
|
16
|
+
"express",
|
|
17
|
+
"express-serve-static-core",
|
|
18
|
+
"@types/express",
|
|
19
|
+
"@types/express-serve-static-core",
|
|
20
|
+
"fastify",
|
|
21
|
+
"@fastify/cookie",
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
/** The response methods that write a cookie by name, value and options, and the ones that write a header by name. */
|
|
25
|
+
const COOKIE_WRITERS = new Set(["cookie", "setCookie"])
|
|
26
|
+
const HEADER_WRITERS = new Set(["setHeader", "append", "header", "set", "appendHeader"])
|
|
27
|
+
const SAME_SITE = new Set(["lax", "strict"])
|
|
28
|
+
|
|
29
|
+
/** The declaration a call resolves to, when it is a method of a response type of the express or fastify family; else null. */
|
|
30
|
+
const responseMethodOf = (checker, toTs, call) => {
|
|
31
|
+
const tsCall = toTs(call)
|
|
32
|
+
const declaration = tsCall ? checker.getResolvedSignature(tsCall)?.declaration : undefined
|
|
33
|
+
if (!declaration || !(ts.isMethodSignature(declaration) || ts.isMethodDeclaration(declaration)) || !declaration.name) return null
|
|
34
|
+
const file = String(declaration.getSourceFile().fileName).split("\\").join("/")
|
|
35
|
+
const pkg = moduleOf(declaration, file)
|
|
36
|
+
return pkg !== null && RESPONSE_PACKAGES.has(pkg) ? declaration.name.getText() : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The present (non-nullish) members of a property's type on an options type, or [] when the property is absent or only nullish. */
|
|
40
|
+
const presentParts = (checker, type, name, at) => {
|
|
41
|
+
const symbol = type.getProperty(name)
|
|
42
|
+
if (!symbol) return []
|
|
43
|
+
const propertyType = checker.getTypeOfSymbolAtLocation(symbol, at)
|
|
44
|
+
const parts = propertyType.isUnion() ? propertyType.types : [propertyType]
|
|
45
|
+
return parts.filter((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isBooleanLiteral = (part, value) => Boolean(part.flags & ts.TypeFlags.BooleanLiteral) && part.intrinsicName === String(value)
|
|
49
|
+
|
|
50
|
+
/** The attributes a written cookie is missing, judged on the type of its options. */
|
|
51
|
+
const missingAttributes = (checker, type, at) => {
|
|
52
|
+
const missing = []
|
|
53
|
+
const httpOnly = presentParts(checker, type, "httpOnly", at)
|
|
54
|
+
if (httpOnly.length === 0 || !httpOnly.every((part) => isBooleanLiteral(part, true))) missing.push("`httpOnly: true`")
|
|
55
|
+
const secure = presentParts(checker, type, "secure", at)
|
|
56
|
+
if (secure.length === 0 || !secure.every((part) => isBooleanLiteral(part, true))) missing.push("`secure: true`")
|
|
57
|
+
const sameSite = presentParts(checker, type, "sameSite", at)
|
|
58
|
+
if (sameSite.length === 0 || !sameSite.every((part) => part.isStringLiteral() && SAME_SITE.has(part.value))) missing.push('`sameSite: "lax"` or `"strict"`')
|
|
59
|
+
return missing
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A cookie a door writes states httpOnly, secure and sameSite, and no door writes `Set-Cookie` by hand. */
|
|
63
|
+
export const responseCookieAttributes = {
|
|
64
|
+
meta: {
|
|
65
|
+
type: "problem",
|
|
66
|
+
docs: { description: "A cookie written through an Express or Fastify response states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`; `Set-Cookie` is never written as a header." },
|
|
67
|
+
schema: [],
|
|
68
|
+
messages: {
|
|
69
|
+
attributes:
|
|
70
|
+
"This cookie is written without {{missing}}. A cookie left to the defaults is readable by page script, travels over plain HTTP and is sent on cross-site requests: pass one options constant, declared `as const` by the owner of the cookie, that states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`.",
|
|
71
|
+
raw: "`Set-Cookie` is written here as a header, which states none of `httpOnly`, `secure` and `sameSite`. Write the cookie through the response's `cookie(name, value, options)` with an options constant that states all three.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
create(context) {
|
|
75
|
+
const { checker, toTs } = typed(context)
|
|
76
|
+
return {
|
|
77
|
+
CallExpression(node) {
|
|
78
|
+
if (node.callee.type !== "MemberExpression") return
|
|
79
|
+
const method = responseMethodOf(checker, toTs, node)
|
|
80
|
+
if (method === null) return
|
|
81
|
+
if (COOKIE_WRITERS.has(method)) {
|
|
82
|
+
const options = node.arguments[2]
|
|
83
|
+
const tsOptions = options ? toTs(options) : undefined
|
|
84
|
+
const missing = tsOptions ? missingAttributes(checker, checker.getTypeAtLocation(tsOptions), tsOptions) : ["`httpOnly: true`", "`secure: true`", '`sameSite: "lax"` or `"strict"`']
|
|
85
|
+
if (missing.length > 0) context.report({ node, messageId: "attributes", data: { missing: missing.join(", ") } })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!HEADER_WRITERS.has(method)) return
|
|
89
|
+
const name = node.arguments[0]
|
|
90
|
+
const tsName = name ? toTs(name) : undefined
|
|
91
|
+
const type = tsName ? checker.getTypeAtLocation(tsName) : undefined
|
|
92
|
+
if (type?.isStringLiteral() && type.value.toLowerCase() === "set-cookie") context.report({ node, messageId: "raw" })
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Every rule this module ships, by the name a config switches it on under. */
|
|
99
|
+
export const rules = {
|
|
100
|
+
"response-cookie-attributes": responseCookieAttributes,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The level a consuming repository switches this on at. */
|
|
104
|
+
export const recommended = {
|
|
105
|
+
"starci-be/response-cookie-attributes": "error",
|
|
106
|
+
}
|
package/cqrs.mjs
CHANGED
|
@@ -118,8 +118,9 @@ export const handlerOverridesProcess = {
|
|
|
118
118
|
/** Statements and expressions that are a decision or a repetition: none of them belongs in a handler. */
|
|
119
119
|
const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
|
|
120
120
|
|
|
121
|
-
/**
|
|
122
|
-
const isServiceType = (context, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null
|
|
121
|
+
/** The one collaborator a handler may hold: a domain service (a class named `*Service` declared in a `*.service.ts` file) or a projection (a class declared in a `*.projection.ts` file of slot `be.projections`). */
|
|
122
|
+
const isServiceType = (context, hfs, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null
|
|
123
|
+
&& ((origin.name.endsWith("Service") && origin.file.endsWith(".service.ts")) || (origin.file.endsWith(".projection.ts") && hfs.slotOf(origin.file) === "be.projections")))
|
|
123
124
|
|
|
124
125
|
/** The injected members of a class: constructor parameters and decorated properties, with the type annotation of each. */
|
|
125
126
|
const injectedMembers = (node) => {
|
|
@@ -140,10 +141,10 @@ const injectedMembers = (node) => {
|
|
|
140
141
|
export const handlerIsThin = {
|
|
141
142
|
meta: {
|
|
142
143
|
type: "problem",
|
|
143
|
-
docs: { description: "A CQRS handler injects only `*Service` classes (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
|
|
144
|
+
docs: { description: "A CQRS handler injects only `*Service` classes or projections (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
|
|
144
145
|
schema: [],
|
|
145
146
|
messages: {
|
|
146
|
-
dependency: "`{{what}}` is injected into a handler. A handler holds services (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
|
|
147
|
+
dependency: "`{{what}}` is injected into a handler. A handler holds services or projections (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
|
|
147
148
|
branch: "`{{what}}` is a decision or a loop in a handler. The `process` of a handler only maps its input and returns what one service method answers; every branch belongs in the service, where it is covered by the service spec.",
|
|
148
149
|
body: "`process` of `{{name}}` must be the single statement `return this.<service>.<method>(<mapped input>)` (with or without `await`). Any other statement is logic that no unit spec covers: move it into the service.",
|
|
149
150
|
call: "`process` of `{{name}}` must call exactly one method of an injected `*Service` and return its result: this makes {{count}} call(s), or calls something that is not an injected service.",
|
|
@@ -158,7 +159,7 @@ export const handlerIsThin = {
|
|
|
158
159
|
const name = node.id?.name ?? "this handler"
|
|
159
160
|
const services = new Set()
|
|
160
161
|
for (const injected of injectedMembers(node)) {
|
|
161
|
-
if (injected.annotation && isServiceType(context, injected.annotation)) {
|
|
162
|
+
if (injected.annotation && isServiceType(context, hfs, injected.annotation)) {
|
|
162
163
|
if (injected.name !== null) services.add(injected.name)
|
|
163
164
|
} else if (!injected.annotation || !isLoggerType(context, injected.annotation)) {
|
|
164
165
|
context.report({ node: injected.report, messageId: "dependency", data: { what: sourceCode.getText(injected.param) } })
|
|
@@ -405,18 +406,9 @@ export const noUseCase = {
|
|
|
405
406
|
|
|
406
407
|
// -- no-event-bus ------------------------------------------------------------------------------------------------
|
|
407
408
|
|
|
408
|
-
/** The
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
/** The package of the second in-process event system. */
|
|
412
|
-
const EVENT_EMITTER_PACKAGE = "@nestjs/event-emitter"
|
|
413
|
-
|
|
414
|
-
/** The Node and RxJS classes that are an in-process event bus when constructed or extended: package -> class names. */
|
|
415
|
-
const BUS_CLASSES = { events: new Set(["EventEmitter"]), "node:events": new Set(["EventEmitter"]), rxjs: new Set(["Subject", "BehaviorSubject", "ReplaySubject", "AsyncSubject"]) }
|
|
416
|
-
|
|
417
|
-
/** The bus class a constructed or extended expression is declared as (by declaration origin), else null. */
|
|
418
|
-
const busClassOf = (context, node) => {
|
|
419
|
-
const origin = typeOrigins(context, node).find((entry) => entry.module !== null && BUS_CLASSES[entry.module]?.has(entry.name))
|
|
409
|
+
/** The bus class a constructed or extended expression is declared as (by declaration origin), else null. `classes` is the `ruleParams.be.eventBus.classes` map: module specifier -> class names. */
|
|
410
|
+
const busClassOf = (context, node, classes) => {
|
|
411
|
+
const origin = typeOrigins(context, node).find((entry) => entry.module !== null && classes[entry.module]?.includes(entry.name))
|
|
420
412
|
return origin ? origin.name : null
|
|
421
413
|
}
|
|
422
414
|
|
|
@@ -434,20 +426,22 @@ const holdsFunctions = (checker, type) => {
|
|
|
434
426
|
return args.length > 0 && isFunctionType(args[args.length - 1])
|
|
435
427
|
}
|
|
436
428
|
|
|
437
|
-
/** No in-process events: a side effect that must happen anyway is an
|
|
429
|
+
/** No in-process events: a side effect that must happen anyway is an event published through the bus. */
|
|
438
430
|
export const noEventBus = {
|
|
439
431
|
meta: {
|
|
440
432
|
type: "problem",
|
|
441
433
|
docs: { description: "`EventBus`, `@EventsHandler`, `IEventHandler` and `@nestjs/event-emitter` are not used." },
|
|
442
434
|
schema: [],
|
|
443
435
|
messages: {
|
|
444
|
-
event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction:
|
|
445
|
-
bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction.
|
|
446
|
-
registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus.
|
|
436
|
+
event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
|
|
437
|
+
bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/`; a state a caller waits for is read from the database.",
|
|
438
|
+
registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
|
|
447
439
|
},
|
|
448
440
|
},
|
|
449
441
|
create(context) {
|
|
450
442
|
const report = (node, name) => context.report({ node, messageId: "event", data: { name } })
|
|
443
|
+
const eventBus = hfsOf(context).ruleParams.eventBus
|
|
444
|
+
const eventNames = new Set(eventBus.imports[CQRS_PACKAGE] ?? [])
|
|
451
445
|
const { checker, toTs } = typed(context)
|
|
452
446
|
/** Collects, per class, the functions-collection properties and the registrations made on them. */
|
|
453
447
|
const checkClass = (classNode) => {
|
|
@@ -483,21 +477,23 @@ export const noEventBus = {
|
|
|
483
477
|
ClassDeclaration(node) {
|
|
484
478
|
checkClass(node)
|
|
485
479
|
if (node.superClass) {
|
|
486
|
-
const name = busClassOf(context, node.superClass)
|
|
480
|
+
const name = busClassOf(context, node.superClass, eventBus.classes)
|
|
487
481
|
if (name) context.report({ node: node.superClass, messageId: "bus", data: { name } })
|
|
488
482
|
}
|
|
489
483
|
},
|
|
490
484
|
ClassExpression(node) { checkClass(node) },
|
|
491
485
|
NewExpression(node) {
|
|
492
|
-
const name = busClassOf(context, node.callee)
|
|
486
|
+
const name = busClassOf(context, node.callee, eventBus.classes)
|
|
493
487
|
if (name) context.report({ node, messageId: "bus", data: { name } })
|
|
494
488
|
},
|
|
495
489
|
...moduleReferences(({ node, source, value, names }) => {
|
|
496
|
-
|
|
497
|
-
|
|
490
|
+
const members = eventBus.imports[value]
|
|
491
|
+
if (members === undefined) return
|
|
492
|
+
if (members.length === 0) report(source, value)
|
|
493
|
+
else for (const name of names) if (members.includes(name)) report(node, name)
|
|
498
494
|
}),
|
|
499
495
|
MemberExpression(node) {
|
|
500
|
-
if (node.computed || node.property.type !== "Identifier" || !
|
|
496
|
+
if (node.computed || node.property.type !== "Identifier" || !eventNames.has(node.property.name)) return
|
|
501
497
|
if (node.object.type === "Identifier" && importOf(context, node.object)?.source === CQRS_PACKAGE && importOf(context, node.object).imported === "*") report(node, node.property.name)
|
|
502
498
|
},
|
|
503
499
|
}
|
package/data-access.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - `must-inject-entity-manager` - a constructor parameter typed `EntityManager` carries the named injector of a declared
|
|
10
10
|
* connection. A lookalike (`InjectFooEntityManager`) or a bare `InjectEntityManager()` is not one.
|
|
11
11
|
* - `named-entity-manager-only` - no property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a
|
|
12
|
-
* `DataSource` or `QueryRunner` is injected only in the platform database capability and the
|
|
12
|
+
* `DataSource` or `QueryRunner` is injected only in the platform database capability and the cli app; no
|
|
13
13
|
* `getRepository(...)` on a manager or data source; and outside those places no type reference, awaited value,
|
|
14
14
|
* or `provide:` token is a `DataSource` or `QueryRunner` (a wrapper service that exposes one is the same door), and no
|
|
15
15
|
* class receives a wrapper class that exposes one.
|
|
@@ -24,11 +24,12 @@
|
|
|
24
24
|
|
|
25
25
|
import ts from "typescript"
|
|
26
26
|
import { walk } from "./lib/ast.mjs"
|
|
27
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
27
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
28
|
+
import { resolveVariable, transactionCallback } from "./lib/transactions.mjs"
|
|
28
29
|
import {
|
|
29
30
|
declaredInjectorNames,
|
|
30
31
|
inDatabaseCapability,
|
|
31
|
-
|
|
32
|
+
inCli,
|
|
32
33
|
implementsMigration,
|
|
33
34
|
infraTypeOf,
|
|
34
35
|
injectionSites,
|
|
@@ -69,18 +70,18 @@ export const namedEntityManagerOnly = {
|
|
|
69
70
|
type: "problem",
|
|
70
71
|
docs: {
|
|
71
72
|
description:
|
|
72
|
-
"No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the
|
|
73
|
+
"No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the cli app and the test world (`src/tests/world`); no `getRepository`.",
|
|
73
74
|
},
|
|
74
75
|
schema: [],
|
|
75
76
|
messages: {
|
|
76
77
|
property:
|
|
77
78
|
"`{{type}}` is injected as a class property. Property injection hides the dependency from the constructor and the app module. Receive the named-injector `EntityManager` as a constructor parameter.",
|
|
78
79
|
infra:
|
|
79
|
-
"`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the
|
|
80
|
+
"`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the cli app and the test world in `src/tests/world`; specs take `world.db.<connection>`; everything else injects the shared `EntityManager` through its named injector and calls it directly.",
|
|
80
81
|
exposed:
|
|
81
|
-
"`{{type}}` is written here outside the platform database capability, the
|
|
82
|
+
"`{{type}}` is written here outside the platform database capability, the cli app and the test world. A wrapper service that returns, holds or hands out a connection is the same door as injecting it: the connection is opened and held only by `platform/database`, and everything else calls the shared EntityManager through its named injector.",
|
|
82
83
|
token:
|
|
83
|
-
"This provider is bound to a `{{type}}`. A token that resolves to a connection object is a second door to the database: only `platform/database`, the
|
|
84
|
+
"This provider is bound to a `{{type}}`. A token that resolves to a connection object is a second door to the database: only `platform/database`, the cli app and the test world provide one.",
|
|
84
85
|
wrapper:
|
|
85
86
|
"`{{wrapper}}` exposes a `{{type}}` through `{{member}}`, and this class receives it. A connection wrapper is not a service dependency: inject the shared EntityManager with its named injector and call it directly.",
|
|
86
87
|
getRepository:
|
|
@@ -90,7 +91,7 @@ export const namedEntityManagerOnly = {
|
|
|
90
91
|
create(context) {
|
|
91
92
|
const hfs = hfsOf(context)
|
|
92
93
|
const filename = context.filename || context.getFilename()
|
|
93
|
-
const mayHoldConnections = inDatabaseCapability(hfs, filename) ||
|
|
94
|
+
const mayHoldConnections = inDatabaseCapability(hfs, filename) || inCli(hfs, filename) || inTestWorld(hfs, filename) || isMigrationFile(hfs, filename)
|
|
94
95
|
const CONNECTION_TYPES = new Set(["DataSource", "QueryRunner"])
|
|
95
96
|
/** The connection type (DataSource / QueryRunner) a value or written type is declared as, else null. */
|
|
96
97
|
const connectionTypeOf = (node) => {
|
|
@@ -286,26 +287,6 @@ export const requireEntityTableName = {
|
|
|
286
287
|
|
|
287
288
|
// -- transactions ----------------------------------------------------------------------------------
|
|
288
289
|
|
|
289
|
-
/** The callback of a `.transaction(...)` call on a manager or data source, or null. */
|
|
290
|
-
const transactionCallback = (context, node) => {
|
|
291
|
-
const callee = node.callee
|
|
292
|
-
if (callee.type !== "MemberExpression" || callee.computed) return null
|
|
293
|
-
if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
|
|
294
|
-
const receiver = infraTypeOf(context, callee.object)
|
|
295
|
-
if (receiver !== "EntityManager" && receiver !== "DataSource") return null
|
|
296
|
-
const callback = node.arguments[node.arguments.length - 1]
|
|
297
|
-
return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** The variable an identifier resolves to, looking outward from the identifier's own scope. */
|
|
301
|
-
const resolveVariable = (sourceCode, node) => {
|
|
302
|
-
for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
|
|
303
|
-
const variable = scope.set.get(node.name)
|
|
304
|
-
if (variable) return variable
|
|
305
|
-
}
|
|
306
|
-
return null
|
|
307
|
-
}
|
|
308
|
-
|
|
309
290
|
/** The leftmost expression of a member chain: `this.deps.em` gives the `this`. */
|
|
310
291
|
const rootOf = (expression) => {
|
|
311
292
|
let current = expression
|
package/default-deny.mjs
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
|
|
14
14
|
* one file cannot see which module the app registered.
|
|
15
15
|
*/
|
|
16
|
+
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
16
17
|
import { decoratorName, keyName } from "./lib/ast.mjs"
|
|
17
18
|
import { isOwnedEnumMember } from "./lib/declared.mjs"
|
|
18
19
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
@@ -49,7 +50,7 @@ export const noUntypedBody = {
|
|
|
49
50
|
const filename = normalizePath(context.filename || context.getFilename())
|
|
50
51
|
if (isDeclarationFile(filename)) return {}
|
|
51
52
|
const slot = hfsOf(context).slotOf(filename) ?? ""
|
|
52
|
-
const inTransport = slot
|
|
53
|
+
const inTransport = isTransportSlot(slot) || slot.startsWith("be.feature.transport.")
|
|
53
54
|
return {
|
|
54
55
|
Identifier(node) {
|
|
55
56
|
if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
|
package/e2e-flow.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold `
|
|
2
|
+
* The rules that hold the e2e half of `knowledge/patterns/be/test.yaml` (catalog R48 `BE_SPEC_QUALITY`).
|
|
3
3
|
*
|
|
4
4
|
* A rule earns its place by being exact: it fires on a syntactic or typed shape, never on a judgement, or it becomes
|
|
5
|
-
* something authors learn to work around. What these four enforce is transport bypass (a bus or an actor called
|
|
5
|
+
* something authors learn to work around. What these four rules enforce is transport bypass (a bus or an actor called
|
|
6
6
|
* directly), a sleep instead of a poll, a branch inside a step, and a testing module built inside a spec instead of
|
|
7
7
|
* booted by the test world (`useTestWorld({ apps })`, `src/tests/world/`). They do not pretend to judge business meaning: whether a file is one flow, whether
|
|
8
8
|
* its steps are named, and what the absence of an effect is proved by are read by a person.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { basename } from "node:path"
|
|
15
15
|
import ts from "typescript"
|
|
16
|
-
import { hfsOf } from "./lib/hfs.mjs"
|
|
16
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
17
17
|
import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
|
|
18
18
|
|
|
19
19
|
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
@@ -172,7 +172,7 @@ export const noBranchInFlowStep = {
|
|
|
172
172
|
!call.callee.computed &&
|
|
173
173
|
call.callee.property.type === "Identifier" &&
|
|
174
174
|
call.callee.property.name === "waitFor" &&
|
|
175
|
-
typeOrigins(context, call.callee.object).some((origin) => hfs
|
|
175
|
+
typeOrigins(context, call.callee.object).some((origin) => inTestWorld(hfs, origin.file))
|
|
176
176
|
|
|
177
177
|
/** True when this node sits inside the callback of an `it(...)` or `test(...)`, and not inside a `waitFor` probe. */
|
|
178
178
|
const insideStep = (node) => {
|
package/event-bus.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the event-bus and queue patterns (catalog R152 `BE_OUTBOX_WRITE_TX`, R153 `BE_EVENT_CONSUMER_SHAPE`,
|
|
3
|
+
* R154 `BE_QUEUE_PRODUCER_SHAPE`).
|
|
4
|
+
*
|
|
5
|
+
* The one durable path of an event is: a domain service writes the outbox row in ITS transaction (`eventBus.publish(event, tx)`), the
|
|
6
|
+
* relay hands the row to Kafka, a consumer in `transport/message` receives it and dispatches one command. A queue job takes the same
|
|
7
|
+
* path through a typed producer (`<queue>Queue.enqueueX(payload, tx)`) and BullMQ. Raw Kafka and BullMQ clients are owned by
|
|
8
|
+
* `platform/event-bus` and `platform/queue` (R90 `BE_INFRA_OWNER`); Nest's in-process buses are refused by `no-event-bus`.
|
|
9
|
+
*
|
|
10
|
+
* Every rule identifies what it judges by TYPE ORIGIN (the `EventBus` port declared by `platform/event-bus`, the `QueueOutbox` port of
|
|
11
|
+
* `platform/queue`, typeorm's `EntityManager`) and by the slot view (tiers, `be.transport.message`, `be.queues`); none matches a
|
|
12
|
+
* variable, class or method name written in the source, except the contract's own members `publish`, `eventName` and `eventId`.
|
|
13
|
+
*/
|
|
14
|
+
import ts from "typescript"
|
|
15
|
+
import { walk } from "./lib/ast.mjs"
|
|
16
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
17
|
+
import { infraTypeOf } from "./lib/persistence.mjs"
|
|
18
|
+
import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
|
|
19
|
+
import { resolveVariable, transactionCallback } from "./lib/transactions.mjs"
|
|
20
|
+
import { typeOrigins, typed } from "./lib/types.mjs"
|
|
21
|
+
|
|
22
|
+
/** The `EventBus` port of `platform/event-bus`. */
|
|
23
|
+
const isEventBusType = (context, node) => isOwnedType(context, node, { name: "EventBus", capability: "event-bus", tier: "platform" })
|
|
24
|
+
|
|
25
|
+
/** The `EventConsumer` port of `platform/event-bus`. */
|
|
26
|
+
const isEventConsumerType = (context, node) => isOwnedType(context, node, { name: "EventConsumer", capability: "event-bus", tier: "platform" })
|
|
27
|
+
|
|
28
|
+
/** The `EventDelivery` type of `platform/event-bus`. */
|
|
29
|
+
const isEventDeliveryType = (context, node) => isOwnedType(context, node, { name: "EventDelivery", capability: "event-bus", tier: "platform" })
|
|
30
|
+
|
|
31
|
+
/** The `QueueOutbox` port of `platform/queue`. */
|
|
32
|
+
const isQueueOutboxType = (context, node) => isOwnedType(context, node, { name: "QueueOutbox", capability: "queue", tier: "platform" })
|
|
33
|
+
|
|
34
|
+
/** True for a file the rules leave alone: the owners themselves, and the test composition (the world, e2e and fixtures). */
|
|
35
|
+
const isExempt = (hfs, file) => {
|
|
36
|
+
const tier = hfs.tierOf(file)
|
|
37
|
+
if (tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)) return true
|
|
38
|
+
return tier === "platform" && ["event-bus", "queue"].includes(ownerNameOf(hfs, file))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The index of the parameter of a signature declaration typed as typeorm's `EntityManager`, or -1. */
|
|
42
|
+
const transactionParameterIndex = (checker, declaration) => {
|
|
43
|
+
const parameters = declaration?.parameters ?? []
|
|
44
|
+
return parameters.findIndex((parameter) => {
|
|
45
|
+
const type = checker.getTypeAtLocation(parameter)
|
|
46
|
+
const symbol = type.getSymbol?.()
|
|
47
|
+
return symbol?.getName() === "EntityManager" && String(symbol.declarations?.[0]?.getSourceFile().fileName ?? "").replace(/\\/g, "/").includes("/node_modules/typeorm/")
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What an expression passed as the transaction is: the transactional manager a `.transaction(async (manager) => ...)` callback
|
|
53
|
+
* receives, or a function parameter typed as the `EntityManager` (a helper that runs inside the caller's transaction).
|
|
54
|
+
* The injected shared manager (`this.entityManager`), a local alias and any other expression are not proof of a transaction.
|
|
55
|
+
*/
|
|
56
|
+
const isTransactionManager = (context, argument) => {
|
|
57
|
+
if (argument?.type !== "Identifier") return false
|
|
58
|
+
const variable = resolveVariable(context.sourceCode, argument)
|
|
59
|
+
const definition = variable?.defs[0]
|
|
60
|
+
if (definition?.type !== "Parameter") return false
|
|
61
|
+
const owner = definition.node
|
|
62
|
+
const call = owner.parent
|
|
63
|
+
if (call?.type === "CallExpression" && transactionCallback(context, call) === owner) return true
|
|
64
|
+
if (owner.parent?.type === "FunctionExpression" && owner.parent.parent?.kind === "constructor") return false
|
|
65
|
+
if (owner.parent?.kind === "constructor") return false
|
|
66
|
+
return infraTypeOf(context, definition.name) === "EntityManager"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The outbox-writing call a node is: `{ kind: "publish", argument }` or `{ kind: "producer", argument }`, or null. */
|
|
70
|
+
const outboxWriteOf = (context, hfs, node) => {
|
|
71
|
+
const callee = node.callee
|
|
72
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "publish" && isEventBusType(context, callee.object)) {
|
|
73
|
+
return { kind: "publish", argument: node.arguments[1], expected: 1 }
|
|
74
|
+
}
|
|
75
|
+
const { checker, toTs } = typed(context)
|
|
76
|
+
const tsNode = toTs(node)
|
|
77
|
+
const declaration = tsNode ? checker.getResolvedSignature(tsNode)?.declaration : undefined
|
|
78
|
+
if (!declaration) return null
|
|
79
|
+
const file = declaration.getSourceFile().fileName
|
|
80
|
+
if (hfs.tierOf(file) !== "queues") return null
|
|
81
|
+
const index = transactionParameterIndex(checker, declaration)
|
|
82
|
+
return index < 0 ? null : { kind: "producer", argument: node.arguments[index], expected: index }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `eventBus.publish(event, tx)` and a queue producer's `enqueueX(payload, tx)` are called only from `modules/domain`, inside its transaction. */
|
|
86
|
+
export const outboxWriteTx = {
|
|
87
|
+
meta: {
|
|
88
|
+
type: "problem",
|
|
89
|
+
docs: { description: "The outbox is written only by a domain service, with the transaction manager of its own transaction." },
|
|
90
|
+
schema: [],
|
|
91
|
+
messages: {
|
|
92
|
+
foreignTier: "`{{call}}` writes the outbox outside `modules/domain`. An event or a queue job is a consequence of a domain change: the domain service that makes the change publishes it inside the same transaction. A handler, a consumer or a job asks a domain service to act, and an integration or a platform capability never publishes.",
|
|
93
|
+
noTransaction: "`{{call}}` does not pass the transaction manager of its own transaction. The outbox row must commit or roll back with the domain change: call it inside `this.entityManager.transaction(async (manager) => { ... })` and pass that `manager` (or a function parameter typed `EntityManager` that the transaction hands down). The injected shared manager, an alias or an outer manager is not the transaction.",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
create(context) {
|
|
97
|
+
const hfs = hfsOf(context)
|
|
98
|
+
if (isExempt(hfs, context.filename)) return {}
|
|
99
|
+
const inDomain = hfs.tierOf(context.filename) === "domain"
|
|
100
|
+
return {
|
|
101
|
+
CallExpression(node) {
|
|
102
|
+
const write = outboxWriteOf(context, hfs, node)
|
|
103
|
+
if (!write) return
|
|
104
|
+
const call = context.sourceCode.getText(node.callee)
|
|
105
|
+
if (!inDomain) context.report({ node, messageId: "foreignTier", data: { call } })
|
|
106
|
+
else if (!isTransactionManager(context, write.argument)) context.report({ node, messageId: "noTransaction", data: { call } })
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The `eventName` literal of the event class an expression names (`typeof OrderPlacedEvent` has a static `eventName`), or null. */
|
|
113
|
+
const eventNameOf = (context, expression) => {
|
|
114
|
+
const { checker, toTs } = typed(context)
|
|
115
|
+
const tsNode = toTs(expression)
|
|
116
|
+
if (!tsNode) return null
|
|
117
|
+
const property = checker.getTypeAtLocation(tsNode).getProperty("eventName")
|
|
118
|
+
if (!property) return null
|
|
119
|
+
const type = checker.getTypeOfSymbolAtLocation(property, tsNode)
|
|
120
|
+
return type.isStringLiteral() ? type.value : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when the node is a `.eventId` member read on a value typed as the `EventDelivery` of `platform/event-bus`. */
|
|
124
|
+
const readsDeliveryEventId = (context, node) =>
|
|
125
|
+
node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.property.name === "eventId" && isEventDeliveryType(context, node.object)
|
|
126
|
+
|
|
127
|
+
/** The kebab stem of an event name: dots become dashes. */
|
|
128
|
+
const stemOfEvent = (name) => name.split(".").join("-")
|
|
129
|
+
|
|
130
|
+
/** A message consumer is the one door of an event: placed in `transport/message`, named after its event, and it forwards the event id. */
|
|
131
|
+
export const eventConsumerShape = {
|
|
132
|
+
meta: {
|
|
133
|
+
type: "problem",
|
|
134
|
+
docs: { description: "A class implementing `EventConsumer` is `transport/message/<event>.consumer.ts`, named after its event class's `eventName`, and forwards `delivery.eventId`; a `.consumer.ts` implements `EventConsumer`." },
|
|
135
|
+
schema: [],
|
|
136
|
+
messages: {
|
|
137
|
+
placement: "`{{name}}` implements `EventConsumer` outside a `.consumer.ts` file of the message transport (`transport/message/<event>.consumer.ts`). A consumer is a transport door: move it there.",
|
|
138
|
+
notConsumer: "This `.consumer.ts` file declares `{{name}}`, which does not implement `EventConsumer` of `platform/event-bus`. The message transport holds only event consumers.",
|
|
139
|
+
eventUnknown: "`{{name}}.event` must be an event class whose static `eventName` is a string literal (`static readonly eventName = \"order.placed\"`), so the consumer and its contract entry are one name.",
|
|
140
|
+
stem: "The consumer of `{{event}}` is `{{expected}}.consumer.ts`, but this file is `{{actual}}.consumer.ts`. A consumer is named after the event it handles (dots become dashes).",
|
|
141
|
+
noEventId: "`{{name}}.handle` never reads `delivery.eventId`. Delivery is at least once: pass the event id to the command so the domain service claims it in `platform/inbox` inside its own transaction and a redelivery is a no-op.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
create(context) {
|
|
145
|
+
const hfs = hfsOf(context)
|
|
146
|
+
const file = context.filename
|
|
147
|
+
if (isExempt(hfs, file)) return {}
|
|
148
|
+
const inTransport = hfs.slotOf(file) === "be.transport.message"
|
|
149
|
+
const isConsumerFile = baseName(file).endsWith(".consumer.ts")
|
|
150
|
+
const check = (node) => {
|
|
151
|
+
const name = node.id?.name ?? "this class"
|
|
152
|
+
const implementsConsumer = (node.implements ?? []).some((entry) => isEventConsumerType(context, entry))
|
|
153
|
+
if (!implementsConsumer) {
|
|
154
|
+
if (inTransport && isConsumerFile) context.report({ node: node.id ?? node, messageId: "notConsumer", data: { name } })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
if (!inTransport || !isConsumerFile) {
|
|
158
|
+
context.report({ node: node.id ?? node, messageId: "placement", data: { name } })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
const eventProperty = node.body.body.find((member) => member.type === "PropertyDefinition" && !member.computed && member.key.type === "Identifier" && member.key.name === "event")
|
|
162
|
+
const event = eventProperty?.value ? eventNameOf(context, eventProperty.value) : null
|
|
163
|
+
if (event === null) context.report({ node: eventProperty ?? node.id ?? node, messageId: "eventUnknown", data: { name } })
|
|
164
|
+
else {
|
|
165
|
+
const actual = baseName(file).slice(0, -".consumer.ts".length)
|
|
166
|
+
if (stemOfEvent(event) !== actual) context.report({ node: node.id ?? node, messageId: "stem", data: { event, expected: stemOfEvent(event), actual } })
|
|
167
|
+
}
|
|
168
|
+
const handle = node.body.body.find((member) => member.type === "MethodDefinition" && !member.computed && member.key.type === "Identifier" && member.key.name === "handle")
|
|
169
|
+
const body = handle?.value?.body
|
|
170
|
+
let forwarded = false
|
|
171
|
+
if (body) walk(body, (child) => { if (readsDeliveryEventId(context, child)) forwarded = true })
|
|
172
|
+
if (!forwarded) context.report({ node: handle ?? node.id ?? node, messageId: "noEventId", data: { name } })
|
|
173
|
+
}
|
|
174
|
+
return { ClassDeclaration: check, ClassExpression: check }
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The functions of an exported class or the exported functions of a `.queue.ts`: the producers. */
|
|
179
|
+
const producersOf = (program) => {
|
|
180
|
+
const found = []
|
|
181
|
+
for (const statement of program.body) {
|
|
182
|
+
const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : null
|
|
183
|
+
if (!declaration) continue
|
|
184
|
+
if (declaration.type === "FunctionDeclaration") {
|
|
185
|
+
found.push(declaration)
|
|
186
|
+
} else if (declaration.type === "ClassDeclaration") {
|
|
187
|
+
for (const member of declaration.body.body) if (member.type === "MethodDefinition" && member.kind === "method" && member.value) found.push(member.value)
|
|
188
|
+
} else if (declaration.type === "VariableDeclaration") {
|
|
189
|
+
for (const item of declaration.declarations) if (item.init && (item.init.type === "ArrowFunctionExpression" || item.init.type === "FunctionExpression")) found.push(item.init)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return found
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A producer of a queue passes its own transaction parameter to the outbox, and nothing else writes the outbox there. */
|
|
196
|
+
export const queueProducerShape = {
|
|
197
|
+
meta: {
|
|
198
|
+
type: "problem",
|
|
199
|
+
docs: { description: "In `modules/queues/<queue>/<queue>.queue.ts` every producer that writes the `QueueOutbox` takes a `tx` parameter typed `EntityManager` and passes it as the transaction." },
|
|
200
|
+
schema: [],
|
|
201
|
+
messages: {
|
|
202
|
+
noTransaction: "This producer writes the queue outbox without passing its own `tx` parameter (typed `EntityManager`) as the first argument. A queue job is enqueued inside the caller's transaction: declare `tx: EntityManager` and call `outbox.write(tx, ...)`.",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
create(context) {
|
|
206
|
+
const hfs = hfsOf(context)
|
|
207
|
+
const file = context.filename
|
|
208
|
+
if (hfs.slotOf(file) !== "be.queues" || !baseName(file).endsWith(".queue.ts")) return {}
|
|
209
|
+
return {
|
|
210
|
+
"Program:exit"(program) {
|
|
211
|
+
for (const producer of producersOf(program)) {
|
|
212
|
+
const parameters = producer.params.filter((param) => param.type === "Identifier")
|
|
213
|
+
const tx = parameters.find((param) => infraTypeOf(context, param) === "EntityManager")
|
|
214
|
+
walk(producer.body, (node) => {
|
|
215
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || !isQueueOutboxType(context, node.callee.object)) return
|
|
216
|
+
const first = node.arguments[0]
|
|
217
|
+
const passes = tx !== undefined && first?.type === "Identifier" && first.name === tx.name && resolveVariable(context.sourceCode, first)?.defs[0]?.name === tx
|
|
218
|
+
if (!passes) context.report({ node, messageId: "noTransaction" })
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export const rules = {
|
|
227
|
+
"outbox-write-tx": outboxWriteTx,
|
|
228
|
+
"event-consumer-shape": eventConsumerShape,
|
|
229
|
+
"queue-producer-shape": queueProducerShape,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The level these laws ask for: `error`, switched off nowhere. */
|
|
233
|
+
export const recommended = {
|
|
234
|
+
"starci-be/outbox-write-tx": "error",
|
|
235
|
+
"starci-be/event-consumer-shape": "error",
|
|
236
|
+
"starci-be/queue-producer-shape": "error",
|
|
237
|
+
}
|