@starci/eslint-canon-be 3.0.8 → 3.0.10
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/LICENSE +21 -0
- package/README.md +12 -6
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +24 -36
- 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 +57 -6
- 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 +5 -2
- 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 +486 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/secrets.mjs +130 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
- 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 +553 -0
- package/runtime/modules/models/runtimes.yaml +695 -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 +18 -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 +2 -2
- 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/jsdoc.mjs +35 -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/mutation-fence.mjs +15 -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 +112 -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/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
|
+
}
|
package/feature-thin.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds the thin-feature law (R203 `BE_FEATURE_THIN`).
|
|
3
|
+
*
|
|
4
|
+
* A file of the feature tier whose role is one of `ruleParams.be.thinRoles` of the slot manifest
|
|
5
|
+
* (`<name>.handler.ts`, `<name>.resolver.ts`, `<name>.consumer.ts`, `<name>.processor.ts`, `<name>.step.ts`,
|
|
6
|
+
* `<name>.webhook.ts`, `<name>.mapper.ts`, ...) is a door: it maps its parameters and makes ONE delegating
|
|
7
|
+
* call into the modules (a service, the command or query bus, the event bus, a queue producer). No branch,
|
|
8
|
+
* loop, `try` or `throw` on business data and no computation lives in it - the logic belongs to the modules,
|
|
9
|
+
* where every file is unit-covered at 100, and orchestration (step order, compensation, claim, fencing,
|
|
10
|
+
* retry) belongs to the platform. A `mapper` file holds only property mapping: `*.mapper.ts` calls, `new`, and the shape conversions `map` and `toISOString`
|
|
11
|
+
* expressions, no delegating call at all.
|
|
12
|
+
*
|
|
13
|
+
* Kind comes from the SLOT and the TIER (`hfs.tierOf(filename)`, `hfs.ruleParams.thinRoles`), the role from
|
|
14
|
+
* the file-name suffix the closed role vocabulary defines - never a path regex. What a receiver is comes
|
|
15
|
+
* from its TYPE (`Logger` of `platform/logging`, `WebhookSignatureService` of `platform/http-security`) and
|
|
16
|
+
* from whether the member is injected (`injectedMembers`); what a free function is comes from the file its
|
|
17
|
+
* import resolves to. A job `step` records its fence through `JobClaims` (`advance`, `runKey`) and a webhook proves its signature through `verify`: those gate calls
|
|
18
|
+
* are free, the one effect each makes is the delegating call. `.spec.ts` and `.d.ts` are never judged, and neither is a file whose tier is not
|
|
19
|
+
* `feature` or whose role is not a thin role. A clean thin file is never flagged: that is the bar.
|
|
20
|
+
*/
|
|
21
|
+
import { keyName, walk } from "./lib/ast.mjs"
|
|
22
|
+
import { aliasTarget, injectedMembers } from "./lib/doors.mjs"
|
|
23
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
24
|
+
import { importOf } from "./lib/import-source.mjs"
|
|
25
|
+
import { baseName, isLoggerType, isOwnedType } from "./lib/ports.mjs"
|
|
26
|
+
|
|
27
|
+
/** The statements and expressions that are a decision, a repetition or an exit: none lives in a thin file. */
|
|
28
|
+
const BRANCH_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement", "LogicalExpression"])
|
|
29
|
+
|
|
30
|
+
/** The expressions that compute a value (a `TemplateLiteral` with an expression joins them). */
|
|
31
|
+
const COMPUTE_NODES = new Set(["BinaryExpression", "UnaryExpression", "UpdateExpression", "AssignmentExpression"])
|
|
32
|
+
|
|
33
|
+
/** The nodes a function can be: the walk for calls and the nesting test use the same set. */
|
|
34
|
+
const FUNCTION_NODES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "MethodDefinition"])
|
|
35
|
+
|
|
36
|
+
/** What one decision or loop node is called in a message. */
|
|
37
|
+
const BRANCH_WHAT = {
|
|
38
|
+
IfStatement: "an `if`",
|
|
39
|
+
SwitchStatement: "a `switch`",
|
|
40
|
+
ConditionalExpression: "a ternary",
|
|
41
|
+
ForStatement: "a `for`",
|
|
42
|
+
ForInStatement: "a `for-in`",
|
|
43
|
+
ForOfStatement: "a `for-of`",
|
|
44
|
+
WhileStatement: "a `while`",
|
|
45
|
+
DoWhileStatement: "a `do-while`",
|
|
46
|
+
TryStatement: "a `try`",
|
|
47
|
+
ThrowStatement: "a `throw`",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The wraps a call may sit under while still being the call of its statement. */
|
|
51
|
+
const STATEMENT_WRAPPERS = new Set(["AwaitExpression", "TSAsExpression", "TSSatisfiesExpression", "TSNonNullExpression", "ChainExpression"])
|
|
52
|
+
|
|
53
|
+
/** The closed-vocabulary role a file name carries (`<name>.<role>.ts` gives `<role>`), or null. */
|
|
54
|
+
const roleOf = (filename) => /\.([a-z][a-z0-9-]*)\.ts$/.exec(baseName(filename))?.[1] ?? null
|
|
55
|
+
|
|
56
|
+
/** The `WebhookSignatureService` of `platform/http-security` - the `verify` a webhook door may call. */
|
|
57
|
+
const isSignatureReceiver = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
|
|
58
|
+
|
|
59
|
+
/** The shape conversions a mapper may make on a value it received: a collection mapped item by item and a date written as text. They decide nothing and compute nothing. */
|
|
60
|
+
const MAPPER_SHAPE_METHODS = new Set(["map", "toISOString"])
|
|
61
|
+
|
|
62
|
+
/** The `JobClaims` port of `platform/jobs` - the fence a job step records itself through (`advance`, `runKey`): bookkeeping the fenced-job pattern requires, free beside the step's one delegating call. */
|
|
63
|
+
const isClaimsReceiver = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
|
|
64
|
+
|
|
65
|
+
/** The `Logger` port of `platform/logging` - the one call that is always free. */
|
|
66
|
+
const isLoggerReceiver = isLoggerType
|
|
67
|
+
|
|
68
|
+
/** The name of a thin file's law is `feature-thin` (R203). */
|
|
69
|
+
export const featureThin = {
|
|
70
|
+
meta: {
|
|
71
|
+
type: "problem",
|
|
72
|
+
docs: { description: "A feature-tier file of a thin role maps its parameters and makes one delegating call into the modules: no branch, loop, `try`, `throw` or computation, and no call other than the one delegate, a modules function in mapping position, a `*.mapper.ts` function or the Logger." },
|
|
73
|
+
schema: [],
|
|
74
|
+
messages: {
|
|
75
|
+
branch: "{{what}} is a decision or a repetition in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and makes one delegating call into the modules; a branch, a loop, a `try` or a `throw` is business logic that belongs in a service of the modules (where a unit spec covers it) - and orchestration (step order, compensation, claim, fencing, retry) belongs to the platform.",
|
|
76
|
+
compute: "{{what}} computes a value in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and hands them over; an expression that computes is logic that belongs in the modules. Map the value there and read it back.",
|
|
77
|
+
call: "`{{what}}` is a call a thin file does not make. It calls exactly one injected member (a service, a bus `execute`, an event bus, a queue producer) or one function of the modules, and pure `*.mapper.ts` functions and the `Logger` port are free{{webhook}}; everything else - parsing, mapping by hand, a helper, a second dependency - is logic that belongs in the modules.",
|
|
78
|
+
many: "`{{name}}` makes a second delegating call here. A thin file makes exactly one; a second is orchestration - step order, compensation, claim, fencing, retry - and belongs to a capability of the platform or inside the service the file calls.",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
create(context) {
|
|
82
|
+
const hfs = hfsOf(context)
|
|
83
|
+
const name = baseName(context.filename)
|
|
84
|
+
if (name.endsWith(".spec.ts") || name.endsWith(".d.ts")) return {}
|
|
85
|
+
const role = roleOf(name)
|
|
86
|
+
if (role === null || !(hfs.ruleParams.thinRoles ?? []).includes(role)) return {}
|
|
87
|
+
if (hfs.tierOf(context.filename) !== "feature") return {}
|
|
88
|
+
const sourceCode = context.sourceCode
|
|
89
|
+
const mapperFile = role === "mapper"
|
|
90
|
+
|
|
91
|
+
/** The injected member names of a class: constructor parameter properties and properties carrying an injector decorator. */
|
|
92
|
+
const memberNames = new Map()
|
|
93
|
+
const injectedNamesOf = (classNode) => {
|
|
94
|
+
if (memberNames.has(classNode)) return memberNames.get(classNode)
|
|
95
|
+
const names = new Set()
|
|
96
|
+
for (const { node } of injectedMembers(context, classNode)) {
|
|
97
|
+
if (node.type === "TSParameterProperty" && node.parameter.type === "Identifier") names.add(node.parameter.name)
|
|
98
|
+
else if (node.type === "PropertyDefinition") {
|
|
99
|
+
const key = keyName(node.key)
|
|
100
|
+
if (key !== null) names.add(key)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
memberNames.set(classNode, names)
|
|
104
|
+
return names
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The class `this` binds at a node's position: the nearest enclosing class, or null when a non-arrow
|
|
109
|
+
* function or an object-literal method rebinds `this` in between (a method's own `FunctionExpression`
|
|
110
|
+
* value is skipped - a method's `this` is the class).
|
|
111
|
+
*/
|
|
112
|
+
const thisClassOf = (node) => {
|
|
113
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
114
|
+
if (parent.type === "ClassDeclaration" || parent.type === "ClassExpression") return parent
|
|
115
|
+
if (parent.type === "FunctionDeclaration") return null
|
|
116
|
+
if (parent.type === "FunctionExpression" && parent.parent?.type !== "MethodDefinition") return null
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The member name of `this.<member>` the call's receiver names, when it is an injected member of the enclosing class. */
|
|
122
|
+
const injectedMemberOf = (object, call) => {
|
|
123
|
+
if (object.type !== "MemberExpression" || object.computed || object.object.type !== "ThisExpression" || object.property.type !== "Identifier") return null
|
|
124
|
+
const classNode = thisClassOf(call)
|
|
125
|
+
if (classNode === null) return null
|
|
126
|
+
const member = object.property.name
|
|
127
|
+
return injectedNamesOf(classNode).has(member) ? member : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** True for a call `f(...)` where `f` is imported from a `*.mapper.ts` (the pure mapping functions). */
|
|
131
|
+
const isMapperCall = (callee) => {
|
|
132
|
+
if (callee.type !== "Identifier") return false
|
|
133
|
+
const found = importOf(context, callee)
|
|
134
|
+
return found !== null && /(?:^|\/)[^/]+\.mapper(?:\.[cm]?[jt]s)?$/.test(found.source.replace(/\\/g, "/"))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True for a call `f(...)` where `f` is imported and its declaration lives outside the feature tier (a function of the modules). */
|
|
138
|
+
const isModulesCall = (callee) => {
|
|
139
|
+
if (callee.type !== "Identifier" || importOf(context, callee) === null) return false
|
|
140
|
+
const symbol = aliasTarget(context, callee)
|
|
141
|
+
return (symbol?.getDeclarations() ?? []).some((declaration) => hfs.tierOf(declaration.getSourceFile().fileName) !== "feature")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True when the call IS the statement it sits in: `f(...)`, `await f(...)`, `return (await) f(...)`, or the whole body of a concise arrow. */
|
|
145
|
+
const isStatementCall = (call) => {
|
|
146
|
+
let current = call
|
|
147
|
+
while (STATEMENT_WRAPPERS.has(current.parent?.type)) current = current.parent
|
|
148
|
+
const parent = current.parent
|
|
149
|
+
return parent?.type === "ExpressionStatement" || parent?.type === "ReturnStatement" || (parent?.type === "ArrowFunctionExpression" && parent.body === current)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What one call is to a thin file: `delegating` (the one call into the modules a file may make),
|
|
154
|
+
* `free` (a mapper, a logger method, a webhook `verify`, a job step's `JobClaims` fence call, a modules function in a mapping position)
|
|
155
|
+
* or `call` (a call a thin file does not make). A `mapper` file holds only mapper calls and `new`.
|
|
156
|
+
*/
|
|
157
|
+
const classify = (call) => {
|
|
158
|
+
const callee = call.callee
|
|
159
|
+
if (callee.type === "Identifier" && isMapperCall(callee)) return "free"
|
|
160
|
+
const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
|
|
161
|
+
if (mapperFile && member !== null && MAPPER_SHAPE_METHODS.has(member.property.name) && injectedMemberOf(member.object, call) === null) return "free"
|
|
162
|
+
// a cli group command shows its help without a sub-command: the `help` of the command nest-commander gives it
|
|
163
|
+
if (role === "cli" && member !== null && member.property.name === "help" && member.object.type === "MemberExpression" && member.object.object.type === "ThisExpression" && member.object.property.type === "Identifier" && member.object.property.name === "command") return "free"
|
|
164
|
+
if (mapperFile) return "call"
|
|
165
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && injectedMemberOf(callee.object, call) !== null) {
|
|
166
|
+
if (isLoggerReceiver(context, callee.object)) return "free"
|
|
167
|
+
if (role === "webhook" && callee.property.name === "verify" && isSignatureReceiver(context, callee.object)) return "free"
|
|
168
|
+
if (role === "step" && isClaimsReceiver(context, callee.object)) return "free"
|
|
169
|
+
return "delegating"
|
|
170
|
+
}
|
|
171
|
+
if (callee.type === "Identifier" && isModulesCall(callee)) return isStatementCall(call) ? "delegating" : "free"
|
|
172
|
+
return "call"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Every root function of the file (a method body, function or arrow not nested in another function and not inside a decorator subtree). */
|
|
176
|
+
const rootFunctionsOf = (program) => {
|
|
177
|
+
const roots = []
|
|
178
|
+
walk(program, (node) => {
|
|
179
|
+
if (!FUNCTION_NODES.has(node.type)) return
|
|
180
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
181
|
+
if (parent.type === "Decorator" || FUNCTION_NODES.has(parent.type)) return
|
|
182
|
+
}
|
|
183
|
+
if (node.type === "MethodDefinition" && node.kind === "constructor") return
|
|
184
|
+
roots.push(node)
|
|
185
|
+
})
|
|
186
|
+
return roots
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The name a `many` message reports for a function root. */
|
|
190
|
+
const nameOf = (fn) => {
|
|
191
|
+
if (fn.type === "MethodDefinition") return keyName(fn.key) ?? "the method"
|
|
192
|
+
if (fn.type === "FunctionDeclaration" && fn.id?.type === "Identifier") return fn.id.name
|
|
193
|
+
const parent = fn.parent
|
|
194
|
+
if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
|
|
195
|
+
if ((parent?.type === "PropertyDefinition" || parent?.type === "Property") && keyName(parent.key)) return keyName(parent.key)
|
|
196
|
+
return "the function"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const visitors = {}
|
|
200
|
+
for (const type of BRANCH_NODES) {
|
|
201
|
+
visitors[type] = (node) => context.report({ node, messageId: "branch", data: { what: type === "LogicalExpression" ? `a \`${node.operator}\`` : BRANCH_WHAT[type], role } })
|
|
202
|
+
}
|
|
203
|
+
for (const type of COMPUTE_NODES) {
|
|
204
|
+
visitors[type] = (node) => context.report({ node, messageId: "compute", data: { what: `an \`${node.operator}\``, role } })
|
|
205
|
+
}
|
|
206
|
+
visitors.TemplateLiteral = (node) => {
|
|
207
|
+
if (node.expressions.length > 0) context.report({ node, messageId: "compute", data: { what: "a template literal with an expression", role } })
|
|
208
|
+
}
|
|
209
|
+
visitors["Program:exit"] = (program) => {
|
|
210
|
+
for (const root of rootFunctionsOf(program)) {
|
|
211
|
+
const body = root.type === "MethodDefinition" ? root.value?.body : root.body
|
|
212
|
+
if (!body) continue
|
|
213
|
+
let delegating = 0
|
|
214
|
+
walk(body, (node) => {
|
|
215
|
+
if (node.type !== "CallExpression") return
|
|
216
|
+
const kind = classify(node)
|
|
217
|
+
if (kind === "call") context.report({ node, messageId: "call", data: { what: sourceCode.getText(node.callee), webhook: role === "webhook" ? ", plus `verify` of the signature service" : "" } })
|
|
218
|
+
else if (kind === "delegating") {
|
|
219
|
+
delegating += 1
|
|
220
|
+
if (delegating === 2) context.report({ node, messageId: "many", data: { name: nameOf(root) } })
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return visitors
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The rules this law contributes to the plugin. */
|
|
230
|
+
export const rules = {
|
|
231
|
+
"feature-thin": featureThin,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Every rule of this law at `error`. */
|
|
235
|
+
export const recommended = {
|
|
236
|
+
"starci-be/feature-thin": "error",
|
|
237
|
+
}
|
package/idempotency.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* - `inbox-dedupe-required` judges every DELIVERY method of a service. Doors (consumers, `SignedWebhook` controllers) are
|
|
10
10
|
* thin (R88): they map the delivery and dispatch one message, so the claim lives in the `*.service.ts` the handler calls,
|
|
11
11
|
* inside its transaction. A delivery method is a public method of a `<name>.service.ts` whose FIRST parameter has an
|
|
12
|
-
* `eventId` property, read by the parameter's TYPE.
|
|
13
|
-
*
|
|
12
|
+
* `eventId` property, read by the parameter's TYPE. Its first awaited work is the Inbox claim, directly or inside
|
|
13
|
+
* the callback of its own EntityManager transaction, with that callback's manager, and the method must return
|
|
14
14
|
* early when the claim answers `false`. A lookalike `Inbox` declared by another owner, a renamed receiver and a
|
|
15
15
|
* property injection are all judged by the receiver's type, so only the real port satisfies the rule; a keyword or a
|
|
16
16
|
* class name never does.
|
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
* if ((await this.inbox.claim(source, id)) === false) { return }
|
|
21
21
|
* const fresh = await this.inbox.claim(source, id)
|
|
22
22
|
* if (!fresh) return
|
|
23
|
-
*
|
|
23
|
+
* A transaction delivery returns or awaits one transaction as its sole statement. The actual selected inline callback
|
|
24
|
+
* claims with its own manager in its first statement, then returns on false; no outer work runs after a duplicate.
|
|
25
|
+
* Direct-claim synchronous effects, getters and called helpers stay review questions (the call graph is not read).
|
|
24
26
|
*/
|
|
25
27
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
26
28
|
import { walk } from "./lib/ast.mjs"
|
|
27
|
-
import { baseName, isOwnedType } from "./lib/ports.mjs"
|
|
28
|
-
import {
|
|
29
|
+
import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
|
|
30
|
+
import { resolveVariable } from "./lib/transactions.mjs"
|
|
31
|
+
import { isPackageType, typed } from "./lib/types.mjs"
|
|
29
32
|
|
|
30
33
|
/** The `Inbox` port of `platform/inbox`. */
|
|
31
34
|
const isInboxType = (context, node) => isOwnedType(context, node, { name: "Inbox", capability: "inbox", tier: "platform" })
|
|
@@ -73,6 +76,52 @@ const firstAwait = (body) => {
|
|
|
73
76
|
return first
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** True when an expression evaluates a call, construction or write before the designated call completes. */
|
|
80
|
+
const hasPriorEffect = (expression, call) => {
|
|
81
|
+
let found = false
|
|
82
|
+
walk(expression, (node) => {
|
|
83
|
+
if (["CallExpression", "NewExpression", "AssignmentExpression", "UpdateExpression"].includes(node.type)
|
|
84
|
+
&& node !== call && node.range[1] < call.range[1]) found = true
|
|
85
|
+
}, { intoFunctions: false })
|
|
86
|
+
return found
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The first awaited claim in the actual callback of a sole, returned or awaited EntityManager transaction. */
|
|
90
|
+
const transactionClaim = (context, body) => {
|
|
91
|
+
if (body.body.length !== 1) return null
|
|
92
|
+
const outer = body.body[0]
|
|
93
|
+
const expression = outer.type === "ReturnStatement" ? outer.argument
|
|
94
|
+
: outer.type === "ExpressionStatement" && outer.expression.type === "AwaitExpression" ? outer.expression : null
|
|
95
|
+
const call = expression?.type === "AwaitExpression" ? expression.argument : expression
|
|
96
|
+
const callee = call?.callee
|
|
97
|
+
if (call?.type !== "CallExpression" || callee.type !== "MemberExpression" || callee.computed
|
|
98
|
+
|| callee.property.type !== "Identifier" || callee.property.name !== "transaction"
|
|
99
|
+
|| !isPackageType(context, callee.object, "EntityManager", "typeorm") || hasPriorEffect(call, call)) return null
|
|
100
|
+
// TypeORM runs arg0 when it is a function, otherwise arg1. Never accept a later, ignored callback.
|
|
101
|
+
const isolation = call.arguments[0]
|
|
102
|
+
const callback = call.arguments.length === 1 ? isolation
|
|
103
|
+
: call.arguments.length === 2 && isolation.type === "Literal"
|
|
104
|
+
&& ["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"].includes(isolation.value)
|
|
105
|
+
? call.arguments[1] : null
|
|
106
|
+
if (!callback || !["ArrowFunctionExpression", "FunctionExpression"].includes(callback.type)
|
|
107
|
+
|| !callback.async || callback.body.type !== "BlockStatement" || callback.params.length !== 1) return null
|
|
108
|
+
const manager = callback.params[0]
|
|
109
|
+
if (manager.type !== "Identifier" || !isPackageType(context, manager, "EntityManager", "typeorm")) return null
|
|
110
|
+
const awaited = firstAwait(callback.body)
|
|
111
|
+
const parent = awaited?.parent
|
|
112
|
+
const statement = parent?.type === "VariableDeclarator" && parent.init === awaited
|
|
113
|
+
&& parent.parent.declarations.length === 1 ? parent.parent
|
|
114
|
+
: ["UnaryExpression", "BinaryExpression"].includes(parent?.type) ? parent.parent
|
|
115
|
+
: parent?.type === "ExpressionStatement" ? parent : null
|
|
116
|
+
if (statement !== callback.body.body[0] || statement?.parent !== callback.body) return null
|
|
117
|
+
const claim = awaited.argument
|
|
118
|
+
const argument = claim?.type === "CallExpression" ? claim.arguments[2] : null
|
|
119
|
+
const managerBinding = resolveVariable(context.sourceCode, manager)
|
|
120
|
+
const argumentBinding = argument?.type === "Identifier" ? resolveVariable(context.sourceCode, argument) : null
|
|
121
|
+
if (!managerBinding || managerBinding.defs[0]?.name !== manager || !argumentBinding
|
|
122
|
+
|| argumentBinding !== managerBinding || hasPriorEffect(statement, claim)) return null
|
|
123
|
+
return awaited
|
|
124
|
+
}
|
|
76
125
|
/** Whether a parameter's type has an `eventId` property: the shape of a delivery. */
|
|
77
126
|
const isDeliveryParam = (context, param) => {
|
|
78
127
|
const target = param.type === "TSParameterProperty" ? param.parameter : param.type === "AssignmentPattern" ? param.left : param
|
|
@@ -99,13 +148,15 @@ export const inboxDedupeRequired = {
|
|
|
99
148
|
const filename = context.filename || context.getFilename()
|
|
100
149
|
const slot = hfs.slotOf(filename)
|
|
101
150
|
if (!baseName(filename).endsWith(".service.ts") || !slot || slot.startsWith("be.tests.")) return {}
|
|
151
|
+
// The event bus publishes events (their shape carries an `eventId`) and its runtime claims the inbox before a consumer runs: no delivery method here.
|
|
152
|
+
if (slot.startsWith("be.platform") && ownerNameOf(hfs, filename) === "event-bus") return {}
|
|
102
153
|
return {
|
|
103
154
|
MethodDefinition(node) {
|
|
104
155
|
if (node.kind !== "method" || node.static || node.key.type === "PrivateIdentifier" || !node.value.body) return
|
|
105
156
|
if (node.accessibility === "private" || node.accessibility === "protected") return
|
|
106
157
|
const first = node.value.params[0]
|
|
107
158
|
if (!first || !isDeliveryParam(context, first)) return
|
|
108
|
-
const awaited = firstAwait(node.value.body)
|
|
159
|
+
const awaited = transactionClaim(context, node.value.body) ?? firstAwait(node.value.body)
|
|
109
160
|
const call = awaited?.argument
|
|
110
161
|
const claims = call?.type === "CallExpression"
|
|
111
162
|
&& call.callee.type === "MemberExpression"
|